Layer 8 Session 45 Beyond the Fundamentals

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.

func() @decorator

A function that wraps another function and returns it changed.

Estimated time: 35โ€“40 minutes  ยท  Pre-quiz โ†’ Concept โ†’ Lab โ†’ Commit โ†’ Post-quiz

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.

Question 1 of 5

You have already written @property (Session 26), @classmethod (Session 18), and @dataclass (Session 30). What do all three have in common, mechanically?

Every one of these is the exact same mechanism: a decorator takes the function or class defined right below it and returns something else in its place โ€” @property wraps a method to allow attribute-style access; @classmethod marks a method to receive the class instead of an instance; @dataclass takes a class and returns an enhanced version with a generated __init__.
Question 2 of 5

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?

Calling 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.
Question 3 of 5

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.
Question 4 of 5

Why does a decorator's inner wrapper function typically accept *args, **kwargs instead of specific named parameters?

Recall Session 09's *args/**kwargs: using them here means the wrapper can transparently accept and forward whatever arguments the ORIGINAL function needs, making the decorator reusable across functions with completely different signatures.
Question 5 of 5

Without functools.wraps, what identity problem does a decorated function have?

Without @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

YOU WRITE@shoutdef greet(name): ...PYTHON RUNSgreet =shout(greet)

@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."
Optional Layer 8: The core, 44-session curriculum is already complete. This session and the two that follow are for anyone who wants to close a few remaining intermediate-level gaps โ€” they build on the capstone project but are not required to consider yourself done.

4. Lab

Lab objective: Write a timing decorator and a validation decorator from scratch, applying both to functions from the Country Explorer project, using functools.wraps correctly.

What you will build

A file called decorators_lab.py.

Step-by-step instructions

1

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
2

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))
3

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))
4

Confirm the validation decorator correctly rejects a negative amount

try:
    grow_population(54000000, -5)
except ValueError as e:
    print("Rejected:", e)
5

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

FileActionWhy
decorators_lab.py Created Custom timing and validation decorators applied to Country Explorer functions.
docs/sessions/session-45/index.html Created This session document.
If you find yourself editing any other file, stop. This session touches exactly 2 files.

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"
Do not commit until you can answer out loud: "Why does the wrapper function need *args, **kwargs instead of specific named parameters?"

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.

Question 1 of 5

You have already written @property (Session 26), @classmethod (Session 18), and @dataclass (Session 30). What do all three have in common, mechanically?

Every one of these is the exact same mechanism: a decorator takes the function or class defined right below it and returns something else in its place โ€” @property wraps a method to allow attribute-style access; @classmethod marks a method to receive the class instead of an instance; @dataclass takes a class and returns an enhanced version with a generated __init__.
Question 2 of 5

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?

Calling 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.
Question 3 of 5

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.
Question 4 of 5

Why does a decorator's inner wrapper function typically accept *args, **kwargs instead of specific named parameters?

Recall Session 09's *args/**kwargs: using them here means the wrapper can transparently accept and forward whatever arguments the ORIGINAL function needs, making the decorator reusable across functions with completely different signatures.
Question 5 of 5

Without functools.wraps, what identity problem does a decorated function have?

Without @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.

  1. 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?
  2. 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?
  3. 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?
  4. 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.