Python Module 4 – Functions, scope, closures and decorators
Decorators: wrapping functions cleanly
See what @decorator really does, write wrappers that keep the name and docstring with functools.wraps, add arguments, stack decorators and decorate classes.
What you will learn
- Explain decorator syntax as rebinding a name to a wrapper
- Write wrappers that pass every argument on and keep metadata with functools.wraps
- Write decorators that take arguments
- Predict the order in which stacked decorators are applied and run
- Write a class decorator that changes and returns the class
Before you start
What you’ll learn · Before you start
What you will learn
- Explain decorator syntax as rebinding a name to a wrapper
- Write wrappers that pass every argument on and keep metadata with functools.wraps
- Write decorators that take arguments
- Predict the order in which stacked decorators are applied and run
- Write a class decorator that changes and returns the class
Before you start
On this page
Adding timing to 15 functions by hand means pasting the same four lines into each one, and taking them out again
later. A decorator: A function that takes a function and returns the one its name should refer to, usually a wrapper. @timed above def report(): has the same effect as report = timed(report) after it. does it once: it is a function that takes a function and returns a
replacement for it, usually a wrapper: A function that calls another function and passes on the arguments it received, usually as func(*args, **kwargs), doing extra work such as logging before or after the call. More in “*args, **kwargs and argument unpacking” that runs some extra code before and after calling
the original. Writing @timed on the line above def report(): has the same effect as report = timed(report) after
it: Python creates the function, passes it to timed, and binds the name report to whatever timed returns.
Below you will watch a name being rebound to a wrapper, follow one call through a wrapper, keep the original’s name
and docstring with functools.wraps, give a decorator arguments, stack two of them, see a decorator do its work as a
module loads, and decorate a method and a class.
@decorator rebinds the name to what the decorator returns
A decorator needs nothing new: it is a function that takes a function, defines a wrapper inside itself and returns
it. The wrapper is a closure, so it remembers func:
def shout(func):
"""Take a function and return a new one that upper-cases its result."""
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
def greet(name):
"""Return a greeting."""
return f"hello, {name}"
loud_greet = shout(greet) # wrapping by hand: greet itself is unchanged
print(greet("Asha"), "|", loud_greet("Asha"))
@shout # the same as farewell = shout(farewell) after the def
def farewell(name):
"""Say goodbye."""
return f"goodbye, {name}"
print(farewell("Ravi"))
print(farewell.__name__, "|", farewell.__doc__) # the wrapper's name, and no docstring Output
hello, Asha | HELLO, ASHA GOODBYE, RAVI wrapper | None
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 by_hand.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
shout(greet)returns a new function and leavesgreetunchanged, so both can be called.@shoutabovedef farewelldoes the same wrapping at once and binds the namefarewellto the wrapper.- The last line shows the price of wrapping:
farewell.__name__is nowwrapperand its docstring is gone, because the namefarewellrefers to the wrapper.
Step through the same idea and watch the names. greet is rebound by hand, and farewell by the @ line, which
Python carries out in four small steps:
Program · rebinding.py
def shout(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
def greet(name):
return f"hello, {name}"
print(greet("Asha"))
greet = shout(greet)
print(greet("Asha"))
@shout
def farewell(name):
return f"goodbye, {name}"
print(farewell("Ravi")) Variables
- changed in this step
Printed so far
Nothing printed yethello, Asha HELLO, ASHA GOODBYE, RAVI
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 11 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
def shout(func):makes the functionshout. Its body runs only when it is called. - Line 7.
def greet(name):makes the functiongreet. Its body runs only when it is called. - Line 11.
print(greet("Asha"))printshello, Asha. - Line 12.
greet = shout(greet)setsgreettowrapper(*args, **kwargs)(it wasgreet(name)). Key point shout(greet) returns its inner function, wrapper, and the assignment rebinds the name greet to it. The original function is not gone; the wrapper holds it as func. - Line 13.
print(greet("Asha"))printsHELLO, ASHA. - Line 16. Line 16 runs. For a decorated def, Python first works out the decorator expression on the @ line, here the function shout.
- Line 17.
def farewell(name):makes the functionfarewell. Its body runs only when it is called. Then the def makes the function object. It is not bound to the name farewell yet. - Line 16. Line 16 runs. Now Python calls shout with the new function. The undecorated function is never bound to the name farewell.
- Line 17.
def farewell(name):makes the functionfarewell. Its body runs only when it is called. Key point The name farewell is bound to what shout returned, its wrapper, exactly as farewell = shout(farewell) would bind it after a plain def. - Line 21.
print(farewell("Ravi"))printsGOODBYE, RAVI. That was the last line, so the program ends. Both names refer to wrappers now. Each call goes through the wrapper, which calls the original and upper-cases its result.
The decorated def worked out shout, made the function, called shout with it and bound farewell to the result.
The language reference describes the @ line in just this way, as farewell = shout(farewell) after a plain def,
except that the original function is never bound to the name farewell at all.
A wrapper passes every argument on and returns the result
The wrapper in shout() accepts *args and **kwargs and passes them on, so it works for a function with any
parameters, and it returns the original’s result. Follow one call through it, from your code to the original and
back:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The call. farewell("Ravi") calls the wrapper, because that is what the name farewell refers to after @shout.
- Arguments collected. The wrapper's args collects the positional arguments, ('Ravi',), and *kwargs the keyword ones, here none, so it accepts whatever the original accepts.
- Passed on unchanged. func(args, *kwargs) spreads them back out, so the original receives exactly the call it would have received without the decorator.
- The original's result. The original returns 'goodbye, Ravi' to the wrapper, which can work on the result before handing it back.
- Returned, changed. The wrapper returns the result upper-cased, 'GOODBYE, RAVI', which the program prints. Without its return statement, the call would give the caller None instead.
A wrapper that drops the result
def wrapper(*args, **kwargs): func(*args, **kwargs) calls the original and throws its result away, so every call
of the decorated function returns None. Write return func(*args, **kwargs).
A timing decorator is the classic first use: start a clock, call the function, stop the clock, report. Putting the
report in a finally block makes it run even when the function raises:
import functools
import time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally: # runs even when func raises
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"{func.__name__} took {elapsed_ms:.2f} ms")
return wrapper
@timed
def sum_of_squares(n):
return sum(i * i for i in range(n))
print(sum_of_squares(200_000)) Output
sum_of_squares took 8.84 ms 2666646666700000
This output changes from run to run: it measures time, which differs from one run to the next and from one computer to another; the computer of this run is named below the output.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 timed.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
time.perf_counter() is the clock for measuring short durations: it has the highest available resolution, and only
the difference between two readings means anything. The time line comes before the result, because the wrapper prints
it before print() gets the return value.
Checkpoint
Check: @decorator as rebinding the name · A wrapper that passes everything on
functools.wraps keeps the name, docstring and signature
functools.wraps(func) is itself a decorator, applied to the wrapper. It copies the original’s identity onto the
wrapper and records the original in a new attribute, __wrapped__. Step through one wrapper made without it and one
made with it, and compare a and b:
Program · wraps_compare.py
import functools
def plain(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def kept(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def farewell(name, punctuation="!"):
"""Say goodbye."""
return f"goodbye, {name}{punctuation}"
a = plain(farewell)
b = kept(farewell)
print(a.__name__, "|", a.__doc__)
print(b.__name__, "|", b.__doc__)
print(b.__wrapped__ is farewell) Variables
- changed in this step
Printed so far
Nothing printed yetwrapper | None farewell | Say goodbye. True
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 10 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
import functoolsloadsfunctools. - Line 4.
def plain(func):makes the functionplain. Its body runs only when it is called. - Line 10.
def kept(func):makes the functionkept. Its body runs only when it is called. - Line 17.
def farewell(name, punctuation='!'):makes the functionfarewell. Its body runs only when it is called. - Line 22.
a = plain(farewell)setsatowrapper(*args, **kwargs). a is the wrapper made without wraps. Its name is wrapper and its signature (*args, **kwargs), which is all repr(), a log line or help() could show. - Line 23.
b = kept(farewell)setsbtofarewell(name, punctuation='!'). Key point b is a wrapper too, but wraps copied the name and the docstring onto it, and inspect.signature() follows its __wrapped__ to the original's parameters. - Line 24.
print(a.__name__, "|", a.__doc__)printswrapper | None. - Line 25.
print(b.__name__, "|", b.__doc__)printsfarewell | Say goodbye.. - Line 26.
print(b.__wrapped__ is farewell)printsTrue. That was the last line, so the program ends. b.__wrapped__ is the original farewell, so a test can still reach the undecorated function.
Here is shout with wraps, on the farewell of the first example:
import functools
import inspect
def shout(func):
@functools.wraps(func) # copy the name, docstring and more from func to wrapper
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@shout
def farewell(name, punctuation="!"):
"""Say goodbye."""
return f"goodbye, {name}{punctuation}"
print(farewell("Ravi"))
print(farewell.__name__, "|", farewell.__doc__, "|", farewell.__qualname__)
print(inspect.signature(farewell)) # follows __wrapped__ to the original
print(farewell.__wrapped__("Ravi")) # the undecorated function is still reachable
print(functools.WRAPPER_ASSIGNMENTS) Output
GOODBYE, RAVI!
farewell | Say goodbye. | farewell
(name, punctuation='!')
goodbye, Ravi!
('__module__', '__name__', '__qualname__', '__doc__', '__annotate__', '__type_params__')
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 keep_metadata.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The decorated farewell now reports its own name, qualified name and docstring, which help(), repr(),
documentation tools and log lines that print func.__name__ all use. inspect.signature() follows __wrapped__ to
the original and shows (name, punctuation='!') instead of the wrapper’s (*args, **kwargs), and
farewell.__wrapped__ lets a test call the undecorated function directly.
Key idea
Put @functools.wraps(func) on every wrapper: the decorated function then keeps its name, docstring and signature,
and __wrapped__ leads back to the original.
The last line of the output lists the attributes wraps copies, functools.WRAPPER_ASSIGNMENTS, and one of them is
new:
Version note
One entry changed in Python 3.14: __annotate__, the function that works out a function’s annotations when they
are first read, took the place of the finished __annotations__ dictionary, because annotations are evaluated
lazily since Python 3.14.
A decorator with arguments is a function that returns a decorator
@retry(times=3) has parentheses, so Python first calls retry(times=3) and uses what that call returns as the
decorator. A decorator with arguments: A function that takes settings and returns a decorator, such as retry(times=3). Python calls it first, at the def, and applies the decorator it returns to the function. is therefore one level deeper: a function that
takes the arguments and returns a decorator, which takes the function and returns a wrapper. Here @retry(times=3)
gives a stock lookup that fails at random up to three attempts:
import functools
import random
def retry(times=3, on=(ConnectionError,)):
"""A decorator factory: retry(times=3) returns the decorator."""
def decorate(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except on as err:
print(f" attempt {attempt} of {times} failed: {err}")
if attempt == times:
raise # give up: the caller sees the last error
return wrapper
return decorate
network = random.Random(1) # seeded, so the "network" fails the same way every run
@retry(times=3)
def fetch_stock(item):
if network.random() < 0.5:
raise ConnectionError("no answer from the warehouse")
return {"tea": 12, "rice": 40}.get(item, 0)
for item in ["tea", "rice", "salt"]:
try:
print(item, fetch_stock(item))
except ConnectionError:
print(item, "unknown: gave up")
print(fetch_stock.__name__) Output
attempt 1 of 3 failed: no answer from the warehouse tea 12 rice 40 attempt 1 of 3 failed: no answer from the warehouse attempt 2 of 3 failed: no answer from the warehouse attempt 3 of 3 failed: no answer from the warehouse salt unknown: gave up fetch_stock
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retry.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The “network” is a random number generator with a fixed seed, so it fails the same way on every run. tea failed
once and succeeded on the second attempt, rice succeeded at once, and salt failed three times, after which the
wrapper re-raised the last ConnectionError for the caller to handle. Step through the three levels:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The parentheses first. @retry(times=3) has parentheses, so Python first calls retry(times=3). This call happens once, when the def runs, not on every call of fetch_stock().
- A decorator comes back. retry() returns decorate, a function that takes a function. That is the decorator Python actually applies.
- The function arrives. Python makes the function fetch_stock and passes it to decorate(), which defines wrapper around it.
- The name is rebound. decorate() returns wrapper, and the name fetch_stock is bound to it. wrapper can still read times and on from retry's call, and func from decorate's.
- Every call. fetch_stock("salt") runs wrapper, which tries up to times = 3 attempts and re-raises the last ConnectionError when all of them fail, as the run shows for salt.
retry stores the settings, decorate receives the function, and wrapper runs on every call, reading times and
on from the enclosing scopes. Real retry decorators also wait between attempts, usually longer each time, and they
retry only errors that are likely to pass, such as a timeout. Never retry an operation that is not safe to repeat,
such as a payment, without a way to recognise the repeat.
Leaving out the parentheses
@retry without them passes the function itself to retry as times, so the name ends up bound to decorate, and
calling fetch_stock("tea") returns yet another function instead of a result. Write @retry() or
@retry(times=3).
Checkpoint
Check: Keeping metadata with functools.wraps · Decorators that take arguments
Stacked decorators apply from the bottom up and run from the top down
Several decorators can sit above one def. The one nearest the function is applied first, and each of the others
wraps the result of the one below it:
import functools
def tag(label):
def decorate(func):
print(f"applying {label} to {func.__name__}")
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f" enter {label}")
result = func(*args, **kwargs)
print(f" leave {label}")
return result
return wrapper
return decorate
@tag("outer")
@tag("inner") # applied first: it is closest to the def
def work():
print(" working")
return 42
print("--- now calling work()")
print(work()) Output
applying inner to work
applying outer to work
--- now calling work()
enter outer
enter inner
working
leave inner
leave outer
42
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stacking.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
What two stacked decorators leave behind
Text description of the diagram
The diagram shows four boxes from top to bottom, joined by arrows.
- At the top is the name work. After the decorators have run it is bound to the outer wrapper, the function that tag("outer") returned.
- The outer wrapper calls the inner wrapper, the function that tag("inner") returned.
- The inner wrapper calls the original work(), the body written under the def.
Python applied the decorators from the bottom up: first tag("inner") wrapped the original function, then tag("outer") wrapped that result. A call therefore travels top-down through the layers: outer, inner, original; and the results come back up in the opposite order.
The two “applying” lines appear before any call, inner first, because decorators run when the def statement runs.
Written out by hand, the two @ lines are work = tag("outer")(tag("inner")(work)), and the innermost call is made
first. The name work ends up bound to the outer wrapper, as the diagram shows, so a call travels from the outside
in. Step through it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Already applied. Both decorators ran when the def ran, nearest first: tag("inner") wrapped the original, then tag("outer") wrapped that. The name work refers to the outer wrapper.
- Enter outer. work() calls the outer wrapper, which prints "enter outer" and calls the function it wraps, the inner wrapper.
- Enter inner. The inner wrapper prints "enter inner" and calls the function it wraps, the original body.
- The body runs. The body prints "working" and returns 42 to the inner wrapper.
- Leave inner. The inner wrapper prints "leave inner" and returns 42 to the outer wrapper.
- Leave outer. The outer wrapper prints "leave outer" and returns 42, which print() shows. The outer wrapper started first and finished last.
The outer wrapper starts first and finishes last. Order changes meaning: a timing decorator placed above a retry decorator measures all the attempts together; placed below it, it times every attempt separately.
Decorators run once, when the def runs
The decorator is called once, when Python executes the def, which for a module is when it is first imported. A
decorator can use that moment to record the function somewhere and return it unchanged. Step through a program that
registers two commands, and watch COMMANDS:
Program · registry.py
COMMANDS = {}
def command(name):
"""Register a function under a name, and return it unchanged."""
def register(func):
COMMANDS[name] = func
return func
return register
print("before the defs:", COMMANDS)
@command("add")
def add(a, b):
return a + b
@command("mul")
def multiply(a, b):
return a * b
print("after the defs:", sorted(COMMANDS)) # filled while the module ran, before any call
print(COMMANDS["mul"](6, 7), multiply(2, 3)) Variables
- changed in this step
Printed so far
Nothing printed yetbefore the defs: {} after the defs: ['add', 'mul'] 42 6
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 14 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
COMMANDS = {}makesCOMMANDSa dict with 0 keys. - Line 4.
def command(name):makes the functioncommand. Its body runs only when it is called. - Line 12.
print("before the defs:", COMMANDS)printsbefore the defs: {}. - Line 15. Line 15 runs. command("add") runs at once and returns register, which is the decorator Python will apply.
- Line 16.
def add(a, b):makes the functionadd. Its body runs only when it is called. - Line 15. Line 15 runs. Key point register(add) has put the new function into COMMANDS and handed it back unchanged. Nothing has called add yet.
- Line 16.
def add(a, b):makes the functionadd. Its body runs only when it is called. - Line 20. Line 20 runs.
- Line 21.
def multiply(a, b):makes the functionmultiply. Its body runs only when it is called. - Line 20. Line 20 runs.
- Line 21.
def multiply(a, b):makes the functionmultiply. Its body runs only when it is called. multiply is registered under "mul" the same way, so COMMANDS is full before any command has run. - Line 25.
print("after the defs:", sorted(COMMANDS))printsafter the defs: ['add', 'mul']. - Line 26.
print(COMMANDS["mul"](6, 7), multiply(2, 3))prints42 6. That was the last line, so the program ends. The dictionary filled while the module itself ran. A decorator's work at definition time happens when a module is imported.
The COMMANDS dictionary was empty before the def statements and full after them, before any command was called.
Press Edit, add a third command, @command("sub") above a def subtract(a, b): that returns a - b, and run it: the
line printed after the defs lists three names, and still nothing has been called. Web frameworks use this pattern to
connect URLs to functions, and command-line tools to connect commands to them. The other side of it: whatever a
decorator does at definition time happens on import, so a slow or failing decorator slows or breaks the import of the
whole module.
Checkpoint
Check: Stacked decorators · Decorators run at definition time
Methods and classes take decorators too
A method is a function defined in a class, so the same wrappers work on methods; self simply arrives as the first
of *args. A class decorator: A decorator written above a class statement. It receives the class once the class body has run, and the class name is bound to what it returns, usually the same class, changed. receives the class instead of a function, after the
class body has run, and returns a class, usually the same one, changed:
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
shown = ", ".join(repr(a) for a in args[1:]) # args[0] is self for a method
print(f"call {func.__qualname__}({shown})")
return func(*args, **kwargs)
return wrapper
def with_repr(cls):
"""A class decorator: give the class a __repr__ that lists its attributes."""
def __repr__(self):
fields = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{cls.__name__}({fields})"
cls.__repr__ = __repr__
return cls # the same class, changed
@with_repr
class Ticket:
def __init__(self, seat, fare):
self.seat = seat
self.fare = fare
@log_calls
def upgrade(self, extra):
self.fare += extra
return self.fare
ticket = Ticket("C12", 450)
print(ticket)
ticket.upgrade(150)
print(ticket)
print(Ticket.upgrade.__qualname__) # the wrapper took the method's name from wraps Output
Ticket(seat='C12', fare=450) call Ticket.upgrade(150) Ticket(seat='C12', fare=600) Ticket.upgrade
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 class_decorator.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
with_repr adds a __repr__ method to Ticket and returns the class, so printing a ticket shows its attributes.
log_calls wraps the method upgrade: self arrives as args[0], which the wrapper leaves out of the line it
prints, and the last line shows that the wrapped method still reports its qualified name, Ticket.upgrade, because
wraps copied it. Step through the order in which Python applies the two:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The body. Python runs the class body like a small module. def __init__ makes a function in the class's namespace.
- A method decorator. The def of upgrade is decorated, so log_calls(upgrade) runs right there, inside the body, and the wrapper takes the name upgrade.
- The class. When the body is done, Python makes the class object from its namespace. The name Ticket is not bound yet.
- The class decorator. with_repr receives the finished class, adds a __repr__ method to it and returns the same class.
- The name. Ticket is bound to what with_repr returned. Had it forgotten return cls, Ticket would be None.
The standard library is full of both kinds: @dataclass and @functools.total_ordering decorate classes, while
@property, @staticmethod, @classmethod and @functools.cache decorate functions and methods. Later lessons
cover each of them.
Checkpoint
Check: Decorating methods and classes
Practise: two guard decorators
The exercise asks for two decorators that guard a function. @validate_positive refuses negative numbers before the
function runs, given by position or by keyword, and @call_limit(n) lets a function run at most n times, each
function with a count of its own. Both must keep the decorated function’s name and docstring, so functools.wraps
earns its place here:
Exercise · Medium · Python
Two guard decorators that keep the function's name
Write two decorators in guards.py:
@validate_positivechecks the arguments of every call before the decorated function runs: if any argument that is anintor afloat, given by position or by keyword, is negative, it raisesValueErrorand the function does not run. Zero is allowed, and arguments of other types, such as strings, are not checked.@call_limit(n)lets the decorated function run at mostntimes. The next call raisesRuntimeErrorwithout running the function. Every function decorated withcall_limithas a count of its own.
Both decorators must keep the decorated function's __name__ and __doc__, and must return whatever the function returns.
Starter code · guards.py
import functools
def validate_positive(func):
"""Raise ValueError when an int or float argument is negative."""
return func
def call_limit(n):
"""Let the decorated function run at most n times; later calls raise RuntimeError."""
def decorate(func):
return func
return decorate The sample tests · test_guards.py
from guards import call_limit, validate_positive
@validate_positive
def area(width, height):
"""Area of a rectangle."""
return width * height
def raises(error, call):
try:
call()
except error:
return True
return False
def test_non_negative_arguments_pass():
"""non-negative numbers, zero included, reach the function"""
assert area(3, 4) == 12
assert area(0, 5) == 0
assert area(width=2.5, height=2) == 5.0
def test_negative_arguments_raise():
"""a negative number, by position or keyword, raises ValueError"""
assert raises(ValueError, lambda: area(-1, 4))
assert raises(ValueError, lambda: area(3, height=-0.5))
def test_other_types_are_not_checked():
"""strings and other types are not checked"""
@validate_positive
def repeat(text, times):
return text * times
assert repeat("ab", 2) == "abab"
assert raises(ValueError, lambda: repeat("ab", -2))
def test_validate_keeps_name_and_doc():
"""@validate_positive keeps __name__ and __doc__"""
assert area.__name__ == "area"
assert area.__doc__ == "Area of a rectangle."
def test_call_limit():
"""the call after the n-th raises RuntimeError and keeps the metadata"""
@call_limit(2)
def ping():
"""Answer a ping."""
return "pong"
assert ping() == "pong"
assert ping() == "pong"
assert raises(RuntimeError, ping)
assert ping.__name__ == "ping"
assert ping.__doc__ == "Answer a ping."
def test_blocked_call_does_not_run():
"""a call over the limit never runs the function"""
ran = []
@call_limit(1)
def record(value):
ran.append(value)
record("first")
assert raises(RuntimeError, lambda: record("second"))
assert ran == ["first"]
def test_each_function_has_its_own_count():
"""two limited functions count separately"""
@call_limit(1)
def first():
return 1
@call_limit(1)
def second():
return 2
assert first() == 1
assert second() == 2 A hint
validate_positive takes the function and returns a wrapper; inside the wrapper, (*args, *kwargs.values()) holds every argument of the call, and isinstance(value, (int, float)) picks the numbers. call_limit(n) is one level deeper: it returns the decorator, which returns the wrapper. Keep the count in the decorator's scope and declare it nonlocal in the wrapper. Put @functools.wraps(func) on each wrapper.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
To finish, a run of questions on wrapping, wraps, arguments, stacking and class decorators. It aims at the idea your
answers so far show you know least, climbs a level when you answer well, and gives every choice its reason:
Practice that adapts to you
Practise this lesson
17 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher to 2–5 years · What does functools.wraps copy?
functools.wraps(func) is a decorator for the wrapper that calls functools.update_wrapper(). It copies the
attributes named in functools.WRAPPER_ASSIGNMENTS from the original function to the wrapper: on Python 3.14,
__module__, __name__, __qualname__, __doc__, __annotate__ and __type_params__. It updates the wrapper’s
__dict__ with the original’s, so custom attributes survive, and it adds __wrapped__, a reference to the original
function. Attributes the original lacks are skipped without an error. It does not change what the wrapper accepts,
which is still *args and **kwargs, but inspect.signature() follows __wrapped__ and reports the original
signature. Without it, help(), repr() and logs that print the function’s name show wrapper and lose the
docstring.
Key takeaways
@decoratorabove adefmeansfunc = decorator(func): the name refers to whatever the decorator returns.- A wrapper takes
*argsand**kwargs, passes them on and returns the original’s result; without thatreturn, every call givesNone. - Put
@functools.wraps(func)on every wrapper: it keeps the name, docstring and qualified name and adds__wrapped__, whichinspect.signature()follows. - A decorator with arguments is a function that returns a decorator: three levels, and the parentheses matter.
- Stacked decorators are applied from the bottom up, and a call passes through them from the top down.
- Decorators run when the function or class is defined, so their side effects happen at import time, and a class decorator must return the class.
Terms in this lesson
- class decorator
- A decorator written above a class statement. It receives the class once the class body has run, and the class name is bound to what it returns, usually the same class, changed.
- decorator
- A function that takes a function and returns the one its name should refer to, usually a wrapper. @timed above def report(): has the same effect as report = timed(report) after it.
- decorator with arguments
- A function that takes settings and returns a decorator, such as retry(times=3). Python calls it first, at the def, and applies the decorator it returns to the function.
References
- Glossary: decorator (Python Software Foundation)
- The Python Language Reference: function definitions (decorators) (Python Software Foundation)
- The Python Language Reference: class definitions (class decorators) (Python Software Foundation)
- functools.wraps() and functools.update_wrapper() (Python Software Foundation)
- inspect.signature() (Python Software Foundation)
- time.perf_counter() (Python Software Foundation)
- PEP 318: decorators for functions and methods (Python Software Foundation)
- PEP 3129: class decorators (Python Software Foundation)
- PEP 749: implementing PEP 649 (wrappers and __annotate__) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress