what is it?

a decorator is a function that takes another function and extends the behavior of the latter function without explicitly modifying it

simple example

some background knowledge - when using functions as first-class objects - a function name without parentheses is a reference to a function, while a function name with trailing parentheses calls the function and refers to its return value

def decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper
 
def say_whee():
    print("Whee!")
 
say_whee = decorator(say_whee)
 
>>> say_whee()
Something is happening before the function is called.
Whee!
Something is happening after the function is called.

using the pie syntax

def decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper
 
@decorator
def say_whee():
    print("Whee!")
 
>>> say_whee()
Something is happening before the function is called.
Whee!
Something is happening after the function is called.

convention is to usually name the inner function wrapper()

handling arguments

use *args and **kwargs in the inner wrapper function to be able to decorate functions with varying amounts of arguments

def do_twice(func):
    def wrapper_do_twice(*args, **kwargs):
        func(*args, **kwargs)
        func(*args, **kwargs)
    return wrapper_do_twice
 
@do_twice
def greet(name):
	print(f"Hello {name}")
 
@do_twice
def say_whee():
	print("Whee!)
 
>>> greet("World")
Hello World
Hello World
 
>>> say_whee()
Whee!
Whee!

returning values from decorated functions

have to return the decorated function in the wrapper function

def do_twice(func):
    def wrapper_do_twice(*args, **kwargs):
        func(*args, **kwargs)
        return func(*args, **kwargs)
    return wrapper_do_twice
 
@do_twice
def return_greeting(name):
    print("Creating greeting")
    return f"Hi {name}"
 
 
>>> return_greeting("Adam")
Creating greeting
Creating greeting
'Hi Adam'

introspection

the ability of an object to know about its own attributes at runtime. after being decorated, functions report being the wrapper functions

to fix this, decorators should use the @functools.wraps decorator, which will preserve information about the original function

import functools
 
def do_twice(func):
    @functools.wraps(func)
    def wrapper_do_twice(*args, **kwargs):
        func(*args, **kwargs)
        return func(*args, **kwargs)
    return wrapper_do_twice
 
@do_twice
def say_whee():
    print("Whee!")
 
 
>>> say_whee
<function say_whee at 0x7ff79a60f2f0>
 
>>> say_whee.__name__
'say_whee'

boilerplate

import functools
 
def decorator(func):
    @functools.wraps(func)
    def wrapper_decorator(*args, **kwargs):
        # Do something before
        value = func(*args, **kwargs)
        # Do something after
        return value
    return wrapper_decorator

nesting decorators

>>> @debug
... @do_twice
... def greet(name):
...     print(f"Hello {name}")

@debug calls @do_twice, which calls greet(), or debug(do_twice(greet()))

decorators with arguments

def repeat(num_times):
    def decorator_repeat(func):
        @functools.wraps(func)
        def wrapper_repeat(*args, **kwargs):
            for _ in range(num_times):
                value = func(*args, **kwargs)
            return value
        return wrapper_repeat
    return decorator_repeat

to make the arguments optional, they have to be specified by keyword. the asterisk (*) syntax, specifies all the following parameters are keyword-only

def name(_func=None, *, key1=value1, key2=value2, ...):
    def decorator_name(func):
        ...  # Create and return a wrapper function.
 
    if _func is None:
        return decorator_name
    else:
        return decorator_name(_func)

NB the function to decorate is only passed in directly if the decorator is called without arguments the _func argument acts as a marker, noting whether the decorator has been called with arguments or not

def repeat(_func=None, *, num_times=2):
    def decorator_repeat(func):
        @functools.wraps(func)
        def wrapper_repeat(*args, **kwargs):
            for _ in range(num_times):
                value = func(*args, **kwargs)
            return value
        return wrapper_repeat
 
    if _func is None:
        return decorator_repeat
    else:
        return decorator_repeat(_func)
 
@repeat
def say_whee():
    print("Whee!")
 
 
@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")
 
>>> say_whee()
Whee!
Whee!
 
>>> greet("Penny")
Hello Penny
Hello Penny
Hello Penny

this is how the above functions look without the @ decorator

def say_whee():
    print("Whee!")
 
say_whee = repeat(say_whee)
 
def greet(name):
    print(f"Hello {name}")
 
greet = repeat(num_times=3)(greet)

classes as decorators

import functools
 
class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.num_calls = 0
 
    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print(f"Call {self.num_calls} of {self.func.__name__}()")
        return self.func(*args, **kwargs)
 
 
@CountCalls
def say_whee():
    print("Whee!")
 
 
>>> say_whee()
Call 1 of say_whee()
Whee!
 
>>> say_whee()
Call 2 of say_whee()
Whee!
 
>>> say_whee.num_calls
2

__init__() method must store a reference to the function, and it can do any other necessary initialisation. use the functools.update_wrapper() function instead of @functools.wraps

for a class instance to be callable, you implement the special .__call__() method