Decorators in Python
A decorator is a design pattern in Python that allows a user to add new functionality to an existing object without modifying its structure. That will enable you to modify the functionality of a function by wrapping it in another function.
We can do it easily because Python functions are first-class citizens. This means that they support operations such as being passed as an argument, returned from a function, modified, and assigned to a variable. Before writing a decorator, we should understand these features.
Assigning Functions to Variables
We can assign the function to a variable and use this variable to call the function.
def square(x):
return x * x
s1 = square
print( s1(5) )
Defining Functions Inside Other Functions (Nested Functions)
We can define a function inside another function.
def square(x):
def s1(x):
return x * x
result = s1(x)
return result
print( square(5) )
# Output:>
# 25
Pass Function as Argument to an Other Function
Functions can also be passed as parameters to other functions.
def square(x):
return x * x
def calc(func, x):
return func(x)
result = calc(square, 5)
print(result)
# Output:>
# 25
Functions Returning Other Functions
A function can also generate another function.
def greeting():
def say_hi():
return "Hi"
return say_hi
hello = greeting()
print( hello() )
# Output:>
# Hi
Built-in “__call__()” Method
It is a built-in method for Python classes. The method lets you write classes that behave like functions and can be called like a function.
class Example:
def __init__(self):
print("Instance Created")
# Defining __call__ method
def __call__(self):
print("Instance is called via special method")
# Instance created
e = Example()
# __call__ method will be called
e()
After learning these concepts, we can try to write a simple decorator function.
def to_camel_case(func):
def wrapper():
result = func()
if isinstance(result, str):
return ' '.join(word.capitalize() for word in result.split(' '))
return result
return wrapper
def itsme():
return 'first last name'
decorate = to_camel_case(itsme)
val = decorate()
print(val)
# Output:>
# FirstLastName
However, we do not use it like that. Python provides a much easier way for us to apply decorators. We simply use the @ symbol before the function we’d like to decorate.
def to_camel_case(func):
def wrapper():
result = func()
if isinstance(result, str):
return ' '.join(word.capitalize() for word in result.split(' '))
return result
return wrapper
@to_camel_case
def get_user_name():
return "first last name"
print(get_user_name())
# Output:>
# FirstLastName
Here is another example. Let’s write a decorator that calculates the time it takes to complete an operation and displays it on the screen.
import time
def calculate_time(func):
"""
Decorator that calculates the execution time of a function.
"""
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
execution_time = end_time - start_time
print(f"Function took {execution_time:.4f} seconds to execute.")
return result
return wrapper
@calculate_time
def my_slow_function(n):
"""Simulates a slow function"""
time.sleep(1)
return n * 2
result = my_slow_function(5)
print(f"Result: {result}")
# Output:>
# Function took 1.0001 seconds to execute.
# Result: 10
This time, since we have a function that takes a parameter, we make the wrapper function parameterizable.
Now let’s make a decorator for writing logs.
import logging
import time
def log_execution(func):
"""A decorator to log the execution of a function."""
logger = logging.getLogger(func.__name__)
logger.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler = logging.FileHandler('function_logs.log')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
def wrapper(*args, **kwargs):
"""A wrapper doc"""
logger.info(f"Calling function '{func.__name__}' with arguments: {args}, {kwargs}")
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
execution_time = end_time - start_time
logger.info(f"Function '{func.__name__}' completed in {execution_time:.4f} seconds.")
return result
return wrapper
@log_execution
def my_function(x, y):
"""A simple function for demonstration"""
time.sleep(0.5) # Simulate some work
return x + y
result = my_function(2, 3)
print(f"Result: {result}")
print(">>>", my_function.__name__)
print(">>>", my_function.__doc__)
# Output:>
# Result: 5
# >>> wrapper
# >>> A wrapper doc
After running the codes above, the contents of “ functions_logs. log ” file:
2024-09-21 22:58:26,791 - my_function - INFO - Calling function 'my_function' with arguments: (2, 3), {}
2024-09-21 22:58:27,295 - my_function - INFO - Function 'my_function' completed in 0.5040 seconds.
You must have noticed “ func.__name__ “ here. This returns the name of the actual function in the wrapper function. But if you use “__name__” outside of the wrapper function, return the wrapper function name. Because decorators work by replacing a function with a new function. Therefore, function metadata, like help() or __doc__, can be lost after decoration. The “ functools.wraps() “ decorator ensures that this metadata is preserved. Debugging tools rely on attributes like __name__ and __module__ to get information about functions. wraps() ensure these attributes are preserved after decoration, making debugging easier.
Import the “ wraps “ package from “ functools ” and add the “ @wraps(func) “ decorator to the wrapper function.
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("Before function execution")
result = func(*args, **kwargs)
print("After function execution")
return result
return wrapper
@my_decorator
def my_function(x, y):
"""This function adds two numbers."""
return x + y
print(my_function(2, 3))
print(my_function.__doc__)
print(my_function.__name__)
print(help(my_function)) # Accesses the documentation of this function
# Output:>
# Before function execution
# After function execution
# 5
# This function adds two numbers.
# my_function
# sh: 1: more: not found
# Help on function my_function in module __main__:
#
# my_function(x, y)
# This function adds two numbers.
Decorators, which are available in many programming languages, are also a very useful tool in Python. There are many ready-made decorators available. By adding them directly to your code, you can make your functions more useful without code clutter.
In fact, you must have been using a lot of decorators in Python before without realizing it. For example; @classmethod, @staticmethod, and @abstractmethod might be the three most used ones
Now you can become more powerful by writing your decorator classes. Here is s simple example.
class MyDecorator:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
print("Decorator is running...")
result = self.func(*args, **kwargs)
print("Decorator operation completed.")
return result
@MyDecorator
def my_function(x, y):
"""This function adds two numbers."""
return x + y
result = my_function(2, 3)
print(f"Result: {result}")
# Output:>
# Decorator is running...
# Decorator operation completed.
# Result: 5
MyDecorator Class:
- It takes a function (func) as a parameter and stores it in the “self.func” attribute.
- The __call__ method makes the decorator behave like a function.
- The __call__ method prints messages before and after the decorator is executed and returns the result of the original function.
Decoration:
- The @MyDecorator syntax decorates the my_function function with the MyDecorator class.
Workflow:
- When you call my_function(2, 3), the decorator gets involved.
- The __call__ method of the MyDecorator class is executed, which calls the my_function function.
- The my_function function runs, returning the result to the wrapper function.
- The wrapper function prints a message indicating that the decorator operation is complete and returns the result.
Let’s finally write our performance log writing decorator example as a class.
import time
import logging
from functools import wraps
class PerformanceLogger:
def __init__(self, func):
self.func = func
self.logger = logging.getLogger(func.__name__)
self.logger.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler = logging.FileHandler('function_logs.log')
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
@wraps(self.func)
def __call__(self, *args, **kwargs):
self.logger.info(f"Calling function '{self.func.__name__}' with arguments: {args}, {kwargs}")
start_time = time.time()
try:
result = self.func(*args, **kwargs)
except Exception as e:
self.logger.error(f"Function '{self.func.__name__}' raised an error: {e}")
raise # Re-raise the exception
else:
end_time = time.time()
execution_time = end_time - start_time
self.logger.info(f"Function '{self.func.__name__}' completed in {execution_time:.4f} seconds.")
return result
@PerformanceLogger
def my_function(x, y):
"""Adds two numbers and waits for 0.5 seconds."""
time.sleep(0.5)
return x + y
try:
result = my_function(2, 3)
print(f"Result: {result}")
except Exception:
print("The function raised an error!")
Decorate your life. :)