Top Python Decorators for Elegant Code: Must-Know Tools for Python Developers

Photo by Spacejoy on Unsplash

Top Python Decorators for Elegant Code: Must-Know Tools for Python Developers

Python decorators are a powerful feature of the language that can be used to modify or extend the behavior of functions and classes. In this article, we'll explore a few of my favorite decorators that you may need frequently.

@dataclass

The @dataclass decorator is a relatively new addition to Python, introduced in version 3.7. It automatically generates methods like __init__(), __repr__(), and __eq__() based on the class's attributes, making it faster and easier to write code for classes that are mainly used for data storage.

Here's an example of how to use @dataclass:

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int
    job: str = "unemployed"

# Create an instance of the Person class
p = Person("Alice", 25, "programmer")

# Print out the attributes of the Person object
print(p.name, p.age, p.job)

In this example, the @dataclass decorator is used to define a Person class with three attributes: name, age, and job. The job attribute has a default value of "unemployed". When an instance of the class is created, its attributes can be accessed and modified like any other Python object.

@lrucache

The @lru_cache decorator is used to cache the results of a function with a limited-size cache. This can be useful when calling a function that is computationally expensive or time-consuming usually recursion problems with overlapping subproblems -bottom-up dynamic programming.

from functools import lru_cache

@lru_cache(maxsize=5)
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(5))  # Output: 5
print(fibonacci(10)) # Output: 55
print(fibonacci(15)) # Output: 610
print(fibonacci(20)) # Output: 6765

In this example, we define a fibonacci function that recursively calculates the nth Fibonacci number. We decorate the function with @lru_cache(maxsize=5), which caches up to 5 most recently used function calls, allowing for faster execution when the same arguments are provided again.

Note that the @lru_cache decorator is especially useful for computationally expensive functions that are called repeatedly with the same arguments, as it can significantly improve their performance. However, it's important to be mindful of the maxsize argument to avoid excessive memory usage.

@retry

The @retry decorator is used to retry a function if it raises an exception. This can be useful when calling a function that depends on an external resource that may be temporarily unavailable, such as a network service.

Here's an example of how to use @retry:

from retry import retry

@retry(Exception, tries=3, delay=1)
def connect_to_service():
    # Code to connect to external service
    pass

In this example, the @retry decorator is used to define a function connect_to_service() that will retry three times with a one-second delay if it raises any exception.

@contextmanager

The @contextmanager decorator is used to define a context manager, which is a way of managing resources that need to be acquired and released in a specific order. Context managers can be used to ensure that resources are properly cleaned up, even if an exception is raised.

Here's an example of how to use @contextmanager:

from contextlib import contextmanager

@contextmanager
def open_file(filename):
    f = open(filename)
    try:
        yield f
    finally:
        f.close()

with open_file('example.txt') as f:
    # Code to read from file
    pass

In this example, the @contextmanager decorator is used to define a context manager open_file() that opens a file and yields the file object to the caller. The finally block ensures that the file is closed when the context is exited, regardless of whether an exception is raised.

Database Connection Use-case

Another important example of how to use the @contextmanager decorator in Python to create a context manager for a database connection class:

from contextlib import contextmanager
import psycopg2

class DatabaseConnection:
    def __init__(self, dbname, user, password, host, port):
        self.conn = psycopg2.connect(
            dbname=dbname,
            user=user,
            password=password,
            host=host,
            port=port
        )

    def query(self, sql):
        cur = self.conn.cursor()
        cur.execute(sql)
        return cur.fetchall()

    def close(self):
        self.conn.close()

@contextmanager
def get_db_connection(dbname, user, password, host, port):
    conn = DatabaseConnection(dbname, user, password, host, port)
    try:
        yield conn
    finally:
        conn.close()

# Example usage:
with get_db_connection('mydb', 'myuser', 'mypassword', 'localhost', '5432') as conn:
    results = conn.query('SELECT * FROM mytable')
    print(results)

In this example, we define a DatabaseConnection class that encapsulates a PostgreSQL database connection, with a query method to execute SQL statements and a close method to close the connection.

We then use the @contextmanager decorator to define a generator function get_db_connection that creates a DatabaseConnection object and yields it to the with statement block, allowing us to use the database connection within the block. When the block exits, the finally block is executed, ensuring that the connection is closed properly.

@timeout

The @timeout decorator can be used to limit the amount of time a function is allowed to execute before raising a TimeoutError. This can be useful when calling a function that may take a long time to execute, or when there is a risk of the function getting stuck in an infinite loop.

Here's an example of how to use @timeout:

from timeout_decorator import timeout

@timeout(5)
def long_running_function():
    # Code that takes a long time to execute
    pass

In this example, the @timeout decorator is used to define a function long_running_function() that will raise a TimeoutError if it takes longer than 5 seconds to execute.

@property

It is similar to the getter setter provided in languages like Java.

allows us to define a method that can be accessed like an attribute. It allows us to define getter methods for attributes that can be accessed without invoking a method.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("Radius must be positive.")
        self._radius = value

    @property
    def diameter(self):
        return self._radius * 2

    @diameter.setter
    def diameter(self, value):
        self.radius = value / 2

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

c = Circle(5)

print(c.radius) # Output: 5
print(c.diameter) # Output: 10
print(c.area) # Output: 78.53975

c.radius = 7
print(c.diameter) # Output: 14
print(c.area) # Output: 153.93791

c.diameter = 12
print(c.radius) # Output: 6
print(c.area) # Output: 113.09724

In this example, we have a Circle class with a radius attribute. We define radius, diameter, and area as read-only properties using the @property decorator.

We also define setter methods for radius and diameter using the @radius.setter and @diameter.setter decorators. This allows us to set the radius and diameter properties using a single method call.

In the example, we create a Circle object with radius 5, and then print out the radius, diameter, and area properties. We then set the radius and diameter properties using the setter methods, and print out the properties again to see the updated values.

@wraps

Creating a custom @debug decorator using wraps

This decorator can be used to print out debugging information when a function is called, including its arguments and return value.

Here's an example of how to create @debug:

from functools import wraps

def debug(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}() with args {args} and kwargs {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__}() returned {result}")
        return result
    return wrapper

@debug
def add_numbers(x, y):
    return x + y

result = add_numbers(2, 3)
print(result)

# Output
# Calling add_numbers() with args (2, 3) and kwargs {}
# add_numbers() returned 5
# 5

In the example above, we defined the debug() function as a decorator that takes in a function func as its argument. The wrapper() function is defined inside the debug() function and takes in *args and **kwargs as its parameters.

Inside the wrapper() function, we print out the name of the function being called along with its input parameters using f-strings. We then call the original function func() with the input parameters and store the result in the result variable.

Finally, we print out the name of the function being called along with its return value using f-strings and return the result.

That's s wrap.

Your engagements are welcome. For more Python tips and tricks subscribe to my blog