Decorators in Python

AkashSDas
7 min readMar 16, 2024

--

Understand the decorator pattern and its implementation in Python.

Decorators in Python

In Python, functions are first class citizens. This allows us to assign function to variables, pass functions through arguments and return function from another function.

Example:

# Accepting function as argument
def logger(operation: callable) -> None:
print(f"Operation {operation.__name__} started")
operation()
print(f"Operation {operation.__name__} ended")


logger(lambda: print("Hello World!"))
# Output:
# Operation <lambda> started
# Hello World!
# Operation <lambda> ended


# Return function from function
def counter() -> callable:
count: int = 0

def increment() -> None:
nonlocal count
count += 1
print(f"Counter: {count}")

return increment


increment_counter = counter()
increment_counter()
increment_counter()
increment_counter()
increment_counter()
# Output:
# Counter: 1
# Counter: 2
# Counter: 3
# Counter: 4

Closures uses this first class function feature and allows us to return function (from another function) which remembers local variables of the scope in which it was created, even when its in some other scope.

In the above example, counter function returns increment function, and later when we call the increment_counter, the function still remembers the count variable of counter function’s scope. This is closure.

Anatomy

Decorator is a design pattern which allow us to add new behavior to functions/objects dynamically without altering the structure (code) of them. Decorators can be created using functions (mostly preferred) or classes.

A function decorator is a function that takes another function as argument, then adds functionality to that function, and then returns another function.

This power extending and modifying existing function/object without altering the source code is both powerful and leaky, and should be used carefully.

The following example displays the functionality of a decorator:

def decorator_funciton(original_function: callable) -> callable:
def wrapper_function() -> None:
print(f"Clean up resources before running: {original_function.__name__}")
return original_function()

return wrapper_function


def compuation():
print("Computation is running")


decorated_compuation = decorator_funciton(compuation)
decorated_compuation()

# Output:
# Clean up resources before running: compuation
# Computation is running

Here, we’re able to add functionality to computation without altering the function itself. Python has built-in support for decorators, so instead of running the decorator function, and then running the returned function, we can decorate computation function with decorator_function. Both are functionally same.

def decorator_funciton(original_function: callable) -> callable:
def wrapper_function() -> None:
print(f"Clean up resources before running: {original_function.__name__}")
return original_function()

return wrapper_function


@decorator_funciton
def compuation():
print("Computation is running")


compuation()

# Output:
# Clean up resources before running: compuation
# Computation is running

In case our function receives arguments, we’ve to pass these argument to the wrapper function. This can be done using args and kwargs.

In the following example, we’re passing arguments to the compuation function:

def decorator_funciton(original_function: callable) -> callable:
def wrapper_function(*args, **kwargs) -> None:
print(f"Clean up resources before running: {original_function.__name__}")
print("Arguments: ", args)
print("Keyword Arguments: ", kwargs)
return original_function(*args, **kwargs)

return wrapper_function


@decorator_funciton
def compuation(ram: int, cpu: int, storage: dict):
print("Computation is running")
print(f"RAM: {ram} GB")
print(f"CPU: {cpu} Cores")
print(f"Storage: {storage}")


compuation(4, 2, {"ssd": 256, "hdd": 1024})

# Output:
# Clean up resources before running: compuation
# Arguments: (4, 2, {'ssd': 256, 'hdd': 1024})
# Keyword Arguments: {}
# Computation is running
# RAM: 4 GB
# CPU: 2 Cores
# Storage: {'ssd': 256, 'hdd': 1024}

Class Decorators

We can also use classes to create decorators. During initializtion (i.e. in __init__ method) we defined the original function (for later use) and the __call__ method becomes the wrapper function.

class DecoratorClass:
def __init__(self, original_function: callable) -> None:
self.original_function = original_function

def __call__(self, *args, **kwargs) -> None:
print(f"Clean up resources before running: {self.original_function.__name__}")
print("Arguments: ", args)
print("Keyword Arguments: ", kwargs)
return self.original_function(*args, **kwargs)


@DecoratorClass
def compuation(ram: int, cpu: int, storage: dict):
print("Computation is running")
print(f"RAM: {ram} GB")
print(f"CPU: {cpu} Cores")
print(f"Storage: {storage}")


compuation(4, 2, {"ssd": 256, "hdd": 1024})

# Output:
# Clean up resources before running: compuation
# Arguments: (4, 2, {'ssd': 256, 'hdd': 1024})
# Keyword Arguments: {}
# Computation is running
# RAM: 4 GB
# CPU: 2 Cores
# Storage: {'ssd': 256, 'hdd': 1024}

Without the decorator annotation (@) we’ll have something like this:


decorated_function = DecoratorClass(compuation) # __init__
decorated_function(4, 2, {"ssd": 256, "hdd": 1024}) # __call__

Sometimes class decorators are more easy to implement as compared to function decorators. Function decorators are mostly used.

Usage

While decorators are powerful, it has some drawbacks. Unnecessary or too much use of it’ll increase complexity of our codebase. It hides details from the user and it make code harder to understand and debug. Just like other design pattern, it should be used based on a problem.

Here we’ll go through few examples of using decorators to solve problems.

1. Logger

Decorators can add logging functionality to functions, allowing us to log inputs, outputs, and other relevant information.

from typing import Any


# =========================
# Function decorator
# =========================


def logger(func: callable) -> callable:
def wrapper(*args, **kwargs):
output = func(*args, **kwargs)
print(f"Function '{func.__name__}' called with args: {args}, kwargs: {kwargs}")
return output

return wrapper


@logger
def addition(x: int, y: int) -> int:
return x + y


print(addition(3, 5))
# Output:
# Function 'addition' called with args: (3, 5), kwargs: {}
# 8


# =========================
# Class decorator
# =========================


class Logger:
def __init__(self, cls: "Addition") -> None:
self.cls = cls

def __call__(self, *args: Any, **kwds: Any) -> Any:
instance = self.cls(*args, **kwds)
print(
f"Class '{self.cls.__name__}' instantiated with args: {args}, kwargs: {kwds}"
)
return instance


@Logger
class Addition:
def __init__(self, x: int, y: int) -> None:
self.x = x
self.y = y

def __call__(self) -> int:
return self.x + self.y


print(Addition(3, 5)())
# Output:
# Class 'Addition' instantiated with args: (3, 5), kwargs: {}

2. Authentication

Using decorator to check for authentication:

from typing import Any


# =========================
# Function decorator
# =========================


def authenticate(func: callable) -> callable:
def wrapper(*args, **kwargs):
print("Authenticating user...")
return func(*args, **kwargs)

return wrapper


@authenticate
def upload_image() -> int:
print("Uploading image...")


upload_image()
# Output:
# Authenticating user...
# Uploading image...


# =========================
# Class decorator
# =========================


class Authenticate:
def __init__(self, func: callable) -> None:
self.func = func

def __call__(self, *args, **kwargs) -> Any:
print("Authenticating user...")
return self.func(*args, **kwargs)


@Authenticate
def upload_image_v2() -> int:
print("Uploading image [v2]...")


upload_image_v2()

# Output:
# Authenticating user...
# Uploading image [v2]...

3. Taking arguments

Decorators can also take arguments. Let’s say we want to retry some functionality for n number of times:

from typing import Any

# =========================
# Function decorator
# =========================


def retry(num_retries: int) -> callable:
def decorator(func: callable) -> callable:
def wrapper(*args, **kwargs) -> Any:
for _ in range(num_retries):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Retrying... {e}")

return wrapper

return decorator


@retry(num_retries=3)
def upload_image() -> int:
raise Exception("Failed to upload image")


upload_image()
# Output:
# Retrying... Failed to upload image
# Retrying... Failed to upload image
# Retrying... Failed to upload image


# =========================
# Class decorator
# =========================


class Retry:
def __init__(self, num_retries: int) -> None:
self.num_retries = num_retries

def __call__(self, func: callable) -> callable:
def wrapper(*args, **kwargs) -> Any:
for _ in range(self.num_retries):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Retrying... {e}")

return wrapper


@Retry(num_retries=3)
def upload_image_v2() -> int:
raise Exception("Failed to upload image [v2]")


upload_image_v2()

# Output:
# Retrying... Failed to upload image [v2]
# Retrying... Failed to upload image [v2]
# Retrying... Failed to upload image [v2]

Notice that in case of using function decorator, we now have 3 nested functions where the outer most function i.e. retry take input arguments.

4. Multiple decorators

We can also attach multiple decorators to functions/objects.

from typing import Any

# =========================
# Function decorator
# =========================


def uppercase(func: callable) -> callable:
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return result.upper()

return wrapper


def exclaim(func: callable) -> callable:
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return f"{result}!"

return wrapper


@uppercase
@exclaim
def greet(name: str) -> str:
return f"Hello {name}"


print(greet("John"))
# Output:
# 'HELLO JOHN!'


# =========================
# Class decorator
# =========================


class UpperCase:
def __init__(self, func: callable) -> None:
self.func = func

def __call__(self, *args, **kwargs) -> str:
result = self.func(*args, **kwargs)
return result.upper()


class Exclaim:
def __init__(self, func: callable) -> None:
self.func = func

def __call__(self, *args, **kwargs) -> str:
result = self.func(*args, **kwargs)
return f"{result}!"


@UpperCase
@Exclaim
class Greeter:
def greet(self, name: str) -> str:
return f"Hello {name}"


print(Greeter().greet("John"))
# Output:
# 'HELLO JOHN!'

In the above example we’re using wraps decorator from functools to preserve the metadata of the decorated function. If we don’t do that then the function passed in the next decorator wrapper looses the original function metadata (name, etc…).

The order of execution is from top to bottom:

def decorator_1(func: callable) -> callable:
print("Decorator 1", func.__name__)

def wrapper_1(*args, **kwargs):
print("Wrapper 1", func.__name__)
return func(*args, **kwargs)

return wrapper_1


def decorator_2(func: callable) -> callable:
print("Decorator 2", func.__name__)

def wrapper_2(*args, **kwargs):
print("Wrapper 2", func.__name__)
return func(*args, **kwargs)

return wrapper_2


@decorator_1
@decorator_2
def my_function():
print("My function")


my_function()

# decorator_2(decorator_1(my_function))()

# Output:
# Decorator 2 my_function
# Decorator 1 wrapper_2
# Wrapper 1 wrapper_2
# Wrapper 2 my_function
# My function

In the above output, wrapper_1 doesn’t have info of my_function like wrapper_2 does. We can preserve this metadata using meta decorator from the functools package.

from functools import wraps


def decorator_1(func: callable) -> callable:
print("Decorator 1", func.__name__)

@wraps(func)
def wrapper_1(*args, **kwargs):
print("Wrapper 1", func.__name__)
return func(*args, **kwargs)

return wrapper_1


def decorator_2(func: callable) -> callable:
print("Decorator 2", func.__name__)

@wraps(func)
def wrapper_2(*args, **kwargs):
print("Wrapper 2", func.__name__)
return func(*args, **kwargs)

return wrapper_2


@decorator_1
@decorator_2
def my_function():
print("My function")


my_function()

# decorator_2(decorator_1(my_function))()

# Output:
# Decorator 2 my_function
# Decorator 1 my_function
# Wrapper 1 my_function
# Wrapper 2 my_function
# My function

--

--