Skip to content

Effortlessly Understanding Python Decorators with Arguments

[

Primer on Python Decorators

by Geir Arne Hjelle | Feb 12, 2024 | Intermediate, Python

In this tutorial, you will learn about Python decorators and how to create and use them. Decorators provide a way to extend the behavior of a function without modifying it directly. By the end of this tutorial, you will understand the concept of first-class objects, how to define decorators, practical use cases for decorators, and best practices for creating them.

Python Functions

Before diving into decorators, let’s first understand some important concepts about functions in Python. A function in Python takes in arguments and returns a value based on those arguments. This fundamental understanding is crucial for understanding decorators.

First-Class Objects

In Python, functions are first-class objects. This means that functions can be assigned to variables, passed as arguments to other functions, and even returned as values from other functions. Here is an example to illustrate this concept:

def greet():
return "Hello, world!"
def welcome(func):
return func()
print(welcome(greet))

Output:

Hello, world!

In the above example, the greet function is assigned to the variable func in the welcome function. Then, the welcome function calls func, which actually invokes the greet function and returns its result.

Inner Functions

In Python, you can define a function inside another function. These are called inner functions. Inner functions have access to the variables in the enclosing scope, even after the outer function has finished executing. Here is an example:

def outer_function(message):
def inner_function():
return message + ' World!'
return inner_function
greet = outer_function('Hello')
print(greet())

Output:

Hello World!

In the above example, the outer_function returns the inner_function, which is then assigned to the variable greet. When greet is called, it still has access to the message variable from the outer_function, resulting in the combined greeting.

Functions as Return Values

Knowing that functions can be assigned to variables and returned as values, we can now explore how this concept connects with decorators. A decorator is a function that takes another function as an argument and extends its behavior without modifying it directly.

def decorator_function(original_function):
def wrapper_function():
print(f'Before {original_function.__name__} is called')
original_function()
print(f'After {original_function.__name__} is called')
return wrapper_function
def greet():
print('Hello, world!')
decorated_greet = decorator_function(greet)
decorated_greet()

Output:

Before greet is called
Hello, world!
After greet is called

In the above example, the decorator_function takes the greet function as an argument and returns the wrapper_function. The wrapper_function adds additional functionality before and after the original greet function is called. The decorated_greet is then invoked, which triggers the whole wrapper code.

Simple Decorators in Python

Now that you understand the basic concepts of functions and how they can be used as first-class objects, let’s move on to creating simple decorators in Python.

Adding Syntactic Sugar

Decorators provide a convenient way to modify the behavior of a function by applying the @decorator_name syntax. This is called syntactic sugar, as it makes the code cleaner and easier to read. Here is an example of adding syntactic sugar to a decorator:

def decorator_function(original_function):
def wrapper_function():
print(f'Before {original_function.__name__} is called')
original_function()
print(f'After {original_function.__name__} is called')
return wrapper_function
@decorator_function
def greet():
print('Hello, world!')
greet()

Output:

Before greet is called
Hello, world!
After greet is called

In the above example, the @decorator_function syntax is used to apply the decorator directly to the greet function. This achieves the same result as before, but with more concise code.

Reusing Decorators

Decorators can be reused on multiple functions, providing the same modified behavior. Here is an example:

def decorator_function(original_function):
def wrapper_function():
print(f'Before {original_function.__name__} is called')
original_function()
print(f'After {original_function.__name__} is called')
return wrapper_function
@decorator_function
def greet():
print('Hello, world!')
@decorator_function
def farewell():
print('Goodbye, world!')
greet()
farewell()

Output:

Before greet is called
Hello, world!
After greet is called
Before farewell is called
Goodbye, world!
After farewell is called

In the above example, the same decorator_function is applied to both the greet and farewell functions. This allows them to both have the same additional functionality before and after the original function is executed.

Decorating Functions With Arguments

So far, the examples have shown decorators applied to functions without arguments. But what if you want to apply a decorator to a function that also accepts arguments? Here is an example:

def decorator_function(original_function):
def wrapper_function(*args, **kwargs):
print(f'Before {original_function.__name__} is called')
original_function(*args, **kwargs)
print(f'After {original_function.__name__} is called')
return wrapper_function
@decorator_function
def greet(name):
print(f'Hello, {name}!')
greet('Alice')

Output:

Before greet is called
Hello, Alice!
After greet is called

In the above example, the wrapper_function is defined with *args and **kwargs as arguments to accept any number of positional and keyword arguments. This allows the decorator to be applied to functions with different argument signatures.

Returning Values From Decorated Functions

In some cases, you may want the decorated function to return a value. To achieve this, you need to modify the wrapper_function to capture the return value of the original function and return it as well. Here is an example:

def decorator_function(original_function):
def wrapper_function(*args, **kwargs):
print(f'Before {original_function.__name__} is called')
result = original_function(*args, **kwargs)
print(f'After {original_function.__name__} is called')
return result
return wrapper_function
@decorator_function
def square(number):
return number ** 2
print(square(5))

Output:

Before square is called
After square is called
25

In the above example, the wrapper_function captures the return value of the original_function in the result variable. This variable is then returned to the caller of the square function.

Finding Yourself

When you use decorators, it is important to be aware that the function name and other attributes are altered. This can cause issues when debugging or checking the type of a decorated function. To solve this problem, you can use the functools.wraps decorator from the functools module. Here is an example:

from functools import wraps
def decorator_function(original_function):
@wraps(original_function)
def wrapper_function(*args, **kwargs):
print(f'Before {original_function.__name__} is called')
original_function(*args, **kwargs)
print(f'After {original_function.__name__} is called')
return wrapper_function
@decorator_function
def greet():
"""A friendly greeting function."""
print('Hello, world!')
print(greet.__name__)
print(greet.__doc__)

Output:

greet
A friendly greeting function.

In the above example, the decorator_function is modified to use the @wraps decorator from the functools module. This ensures that the attributes of the original function, such as __name__ and __doc__, are preserved.

A Few Real World Examples

Now that you have a good grasp of how decorators work, it’s time to explore some practical use cases for decorators.

Timing Functions

A common use case for decorators is to measure the execution time of a function. Here is an example:

import time
def timer(original_function):
@wraps(original_function)
def wrapper_function(*args, **kwargs):
start_time = time.time()
result = original_function(*args, **kwargs)
end_time = time.time()
print(f'{original_function.__name__} took {end_time - start_time} seconds')
return result
return wrapper_function
@timer
def long_running_function():
time.sleep(2)
long_running_function()

Output:

long_running_function took 2.004023551940918 seconds

In the above example, the timer decorator measures the time it takes for the long_running_function to execute by recording the start and end times before and after calling the function. The duration is then printed along with the function name.

Debugging Code

Another use case for decorators is to add debugging functionality to a function. Here is an example:

def debugger(original_function):
@wraps(original_function)
def wrapper_function(*args, **kwargs):
print(f'Calling {original_function.__name__} with args={args} kwargs={kwargs}')
result = original_function(*args, **kwargs)
print(f'{original_function.__name__} returned {result}')
return result
return wrapper_function
@debugger
def calculate_sum(a, b):
return a + b
print(calculate_sum(5, 3))

Output:

Calling calculate_sum with args=(5, 3) kwargs={}
calculate_sum returned 8
8

In the above example, the debugger decorator adds print statements before and after calling the calculate_sum function. This helps with understanding the flow of the program and the values of the arguments.

Slowing Down Code

Sometimes you may want to slow down certain functions for testing or simulation purposes. Here is an example:

def slow_down(original_function):
@wraps(original_function)
def wrapper_function(*args, **kwargs):
print(f'Waiting before calling {original_function.__name__}')
time.sleep(1)
return original_function(*args, **kwargs)
return wrapper_function
@slow_down
def hello_world():
print('Hello, world!')
hello_world()

Output:

Waiting before calling hello_world
Hello, world!

In the above example, the slow_down decorator pauses the execution for one second before calling the hello_world function. This can be useful when you need to simulate delays or control the pacing of your code.

Registering Plugins

Decorators can also be used to automatically register functions as plugins in a system. Here is an example:

registered_plugins = []
def register_plugin(original_function):
registered_plugins.append(original_function)
return original_function
@register_plugin
def plugin1():
print('Plugin 1 is registered')
@register_plugin
def plugin2():
print('Plugin 2 is registered')
print(registered_plugins)

Output:

[<function plugin1 at 0x000001>, <function plugin2 at 0x000002>]

In the above example, the register_plugin decorator appends the original function to a list of registered plugins. This allows you to dynamically build up a collection of plugins without explicitly adding them to the list.

Authenticating Users

Decorators can be used for user authentication, ensuring that only authorized users can access certain functions. Here is an example:

authenticated_user = 'Alice'
def authenticate(original_function):
@wraps(original_function)
def wrapper_function(*args, **kwargs):
if authenticated_user:
return original_function(*args, **kwargs)
else:
print('Access denied')
return wrapper_function
@authenticate
def access_secure_data():
print('Accessing secure data')
access_secure_data()

Output (with authenticated_user = 'Alice'):

Accessing secure data

Output (with authenticated_user = ''):

Access denied

In the above example, the authenticate decorator checks if the authenticated_user is defined before allowing access to the access_secure_data function. If the user is authenticated, the function is executed as normal. Otherwise, an error message is printed.

Conclusion

In this tutorial, you learned about Python decorators and how to create and use them. You explored the concept of first-class objects and how functions can be assigned to variables, passed as arguments, and returned from other functions. You saw practical use cases for decorators, such as timing functions, debugging code, slowing down code, registering plugins, and authenticating users. You also learned best practices, such as using the @wraps decorator to preserve the attributes of original functions. With this knowledge, you are now equipped to use decorators effectively in your Python programs.

Further Reading