Decorators in Python

Batur Orkun
5 min readSep 21, 2024

--

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. :)

--

--