Decorators
Layer 8 begins โ optional, bonus sessions beyond the core 44-session curriculum, for anyone who wants to reach further into intermediate Python. You have used four decorators already without ever seeing how one is built.
A function that wraps another function and returns it changed.
1. Learning Objective
By the end of this session you will be able to:
- Explain that a decorator is just a function that takes a function and returns a (usually wrapped) function
- Write your own decorator from scratch using def and *args/**kwargs
- Apply @your_decorator syntax and explain what it desugars to
- Recognise @property, @classmethod, and @dataclass (already used) as decorators built on this same mechanism
- Use functools.wraps and explain why it matters for a decorated function's identity
2. Pre-Coding Quiz
Answer these before reading the concept explanation or writing any code. It is fine to get these wrong โ that is the point. You need 4/5 to proceed to the lab.
You have already written @property (Session 26), @classmethod (Session 18), and @dataclass (Session 30). What do all three have in common, mechanically?
Given def shout(func):\n def wrapper(*args, **kwargs):\n result = func(*args, **kwargs)\n return result.upper()\n return wrapper, what does shout actually return when called?
shout(func) does not run func immediately โ it returns the wrapper function object itself. The uppercasing only happens later, whenever the returned wrapper is actually called.Given the shout decorator above, what does @shout\ndef greet(name):\n return f"hello {name}" followed by greet("kenya") desugar to and return?
@shout directly above a function definition is exactly equivalent to writing greet = shout(greet) right after defining it. So greet now actually refers to wrapper, which calls the original greet logic and uppercases the result.Why does a decorator's inner wrapper function typically accept *args, **kwargs instead of specific named parameters?
Without functools.wraps, what identity problem does a decorated function have?
@functools.wraps(func) applied to the wrapper, Python has no way to know the wrapper is "standing in for" the original โ its __name__, docstring, and other metadata all show the generic wrapper's identity instead of the real function's, which is confusing when debugging or reading documentation.3. The Concept โ Decorators โ the General Mechanism
@shout above a def is exactly shorthand for greet = shout(greet) โ the decorator swaps in a wrapped version.
What every decorator you've already used has in common
By Session 44 you had already written @property, @classmethod, and @dataclass โ but they were introduced as individual tools, not as one general mechanism. All three (and every decorator, including ones you write yourself) follow the exact same rule: a decorator is a callable that takes the function or class below it and returns something to use in its place.
# You've already seen this shape three times:
class Country:
@property
def total_population(self):
return sum(...)
@classmethod
def from_dict(cls, data):
return cls(**data)
@dataclass
class Country:
name: str
# All three follow the identical underlying rule:
# @decorator_name
# def or class below it
#
# is equivalent to:
# name = decorator_name(name)
Writing your own decorator
A decorator is just a function that takes a function as its argument and returns a (usually new, wrapped) function. Nothing about this requires special syntax beyond what you already know: def, function calls, and returning a function object.
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name):
return f"hello {name}"
print(greet("kenya")) # "HELLO KENYA"
Why *args, **kwargs in the wrapper
Recall Session 09: a decorator should work on ANY function, no matter its specific parameters. The wrapper accepts *args and **kwargs so it can transparently forward whatever arguments the real function actually needs, without the decorator needing to know its exact signature in advance.
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args}, {kwargs}")
return func(*args, **kwargs)
return wrapper
@log_call
def grow_population(country, amount):
country.population += amount
return country.population
# log_call works on grow_population despite never being written with
# grow_population's specific signature in mind
functools.wraps โ preserving the original function's identity
Without help, a decorated function's __name__ and docstring show the generic wrapper's identity, not the original's โ confusing anyone (including you) inspecting or debugging it later. functools.wraps fixes this in one line.
import functools
def shout(func):
@functools.wraps(func) # preserves greet's __name__, docstring, etc.
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name):
"""Return a friendly greeting."""
return f"hello {name}"
print(greet.__name__) # "greet" โ not "wrapper", thanks to functools.wraps
print(greet.__doc__) # "Return a friendly greeting."
4. Lab
What you will build
A file called decorators_lab.py.
Step-by-step instructions
Create the file and write a timing decorator
# decorators_lab.py
import functools
import time
def timed(func):
@functools.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:.6f}s")
return result
return wrapper
Apply it to a country-related function
@timed
def total_population(countries):
return sum(c["population"] for c in countries)
countries = [
{"name": "Kenya", "population": 54000000},
{"name": "Ghana", "population": 31000000},
]
print(total_population(countries))
Write a validation decorator that rejects negative numeric arguments
def positive_only(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for value in list(args) + list(kwargs.values()):
if isinstance(value, (int, float)) and value < 0:
raise ValueError(f"{func.__name__} received a negative value: {value}")
return func(*args, **kwargs)
return wrapper
@positive_only
def grow_population(current, amount):
return current + amount
print(grow_population(54000000, 1000000))
Confirm the validation decorator correctly rejects a negative amount
try:
grow_population(54000000, -5)
except ValueError as e:
print("Rejected:", e)
Confirm functools.wraps preserved both functions' real names
print(total_population.__name__) # "total_population", not "wrapper"
print(grow_population.__name__) # "grow_population", not "wrapper"
5. Expected Files Changed
| File | Action | Why |
|---|---|---|
decorators_lab.py |
Created | Custom timing and validation decorators applied to Country Explorer functions. |
docs/sessions/session-45/index.html |
Created | This session document. |
6. Commit Checkpoint
Once the lab is complete and you can explain every line, make this exact commit:
git add decorators_lab.py docs/sessions/session-45/index.html
git commit -m "session-45: write custom decorators using functools.wraps"
7. Code Review Checklist
Go through your code line by line and check each item:
8. Post-Coding Quiz
Same 5 questions. Take it again now that you have written and run the code. You need 4/5 to mark this session complete.
You have already written @property (Session 26), @classmethod (Session 18), and @dataclass (Session 30). What do all three have in common, mechanically?
Given def shout(func):\n def wrapper(*args, **kwargs):\n result = func(*args, **kwargs)\n return result.upper()\n return wrapper, what does shout actually return when called?
shout(func) does not run func immediately โ it returns the wrapper function object itself. The uppercasing only happens later, whenever the returned wrapper is actually called.Given the shout decorator above, what does @shout\ndef greet(name):\n return f"hello {name}" followed by greet("kenya") desugar to and return?
@shout directly above a function definition is exactly equivalent to writing greet = shout(greet) right after defining it. So greet now actually refers to wrapper, which calls the original greet logic and uppercases the result.Why does a decorator's inner wrapper function typically accept *args, **kwargs instead of specific named parameters?
Without functools.wraps, what identity problem does a decorated function have?
@functools.wraps(func) applied to the wrapper, Python has no way to know the wrapper is "standing in for" the original โ its __name__, docstring, and other metadata all show the generic wrapper's identity instead of the real function's, which is confusing when debugging or reading documentation.9. Reflection Questions
Think through these after the post-quiz. No right answer โ they are for discussion.
- Now that you've written one from scratch, look back at @property from Session 26 โ can you sketch, in rough terms, what its internal wrapper might be doing differently from a normal decorator?
- What would happen if you applied @positive_only to a function that takes a string argument? Would it cause a problem? Why or why not?
- Why is functools.wraps considered a best practice rather than a strict requirement โ what actually breaks if you skip it, versus what merely becomes less convenient?
- Can you think of a cross-cutting concern in the Country Explorer project (logging, timing, validation, caching) that a decorator would be a cleaner fit for than modifying every function individually?
10. What Breaks If This Knowledge Is Missing?
- Decorators looking like unexplained magic: Without this session, @property, @classmethod, and @dataclass remain memorized syntax rather than understood mechanisms โ meaning a new, unfamiliar decorator from a library you use later would be genuinely mysterious instead of immediately recognisable.
- Debugging decorated functions: Skipping functools.wraps causes real, confusing debugging sessions in larger projects, where stack traces and help() output show a generic "wrapper" instead of the actual function name you are trying to trace.
11. What We Learned
Python concept mastered: Decorators as a general mechanism โ writing your own with *args/**kwargs, and recognising @property/@classmethod/@dataclass as instances of the same pattern.
Unlocks: Every decorator you encounter from here forward โ in this project or any future one โ is now a readable, understandable pattern instead of unexplained syntax.
Next session: Session 46 โ Generators, Iterators & Context Managers. We look at how for loops actually work under the hood, and write our own iterables, generators, and context managers.