Python — Understanding Advanced Concepts with Ease: Day 6 (Decorators)

Dipan Saha
9 min readJan 28, 2024

--

Photo by David Clode on Unsplash

Welcome back to Day 6 of our Python — Understanding Advanced Concepts with Ease series! Let’s learn something new today.

Decorators

Python decorators are functions that modify the behavior of other functions or methods. They allow you to add functionality to existing functions or methods without changing their code. In simple terms, decorators are like wrappers around functions, allowing you to extend or modify their behavior.

Now let’s understand it with an example. Suppose you have a function that adds two numbers together:

def add(a, b):
return a + b

Let’s say you want to add some additional functionality to this function, such as logging each time it’s called. Instead of modifying the add() function directly, you can use a decorator.

Here’s how you can create a decorator to log function calls:

def logger(func):
def wrapper(*args, **kwargs):
print(f"Calling function {func.__name__} with arguments {args}")
return func(*args, **kwargs)
return wrapper

You can now apply the logger decorator to the add() function:

@logger
def add(a, b):
return a + b

Now, when you call the add() function, instead of the actual function, the decorator logger will be executed (which in turn, will also call the add function and return the result from it)

result = add(3, 5)
print(result) # Output: Calling function add with arguments (3, 5)
# 8

Here, in this example:

  • The logger decorator is a function that takes another function (func) as its argument.
  • Inside the logger decorator, there's a nested function called wrapper that adds the logging functionality.
  • The wrapper function logs the name of the function being called and its arguments before calling the original function (func).
  • Finally, the logger decorator returns the wrapper function.
  • By using the @logger syntax, we apply the logger decorator to the add() function, which means that calls to add() will first go through the logger decorator.

Here is a simple decorator that clearly explains the wrapper functionality:

def my_decorator(func):
def wrapper():
print("Something is happening before the function is called.")
func()
print("Something is happening after the function is called.")
return wrapper

@my_decorator
def say_hello():
print("Hello!")

say_hello()

Here are some more examples of decorators.

  • Measure the time taken by a function to execute.
import time

def timer(func):
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"Execution time: {end_time - start_time} seconds")
return result
return wrapper

@timer
def some_function():
time.sleep(2)
print("Function executed")

some_function()
  • Check if a user is authorized to access a function.
def authorize(func):
def wrapper(user):
if user.is_authenticated:
return func(user)
else:
raise PermissionError("User is not authorized")
return wrapper

@authorize
def secret_function(user):
print(f"Welcome {user.username}, you have access to the secret function")

secret_function(authorized_user)
  • Automatically retry a function if it fails.
import time

def retry(retries=3, delay=1):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(retries):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Function {func.__name__} failed: {e}")
time.sleep(delay)
raise RuntimeError(f"Function {func.__name__} failed after {retries} retries")
return wrapper
return decorator

@retry()
def unreliable_function():
import random
if random.random() > 0.5:
return "Success"
else:
raise ValueError("Random error")

print(unreliable_function())

You could have also written this as below [which is also an example of a decorator with arguments]:

import time

def retry(retries, delay):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(retries):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Function {func.__name__} failed: {e}")
time.sleep(delay)
raise RuntimeError(f"Function {func.__name__} failed after {retries} retries")
return wrapper
return decorator

@retry(retries=3, delay=1)
def unreliable_function():
import random
if random.random() > 0.5:
return "Success"
else:
raise ValueError("Random error")

print(unreliable_function())
  • Memoization (Caching) Decorator:
import functools

def memoize(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper

@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(10))
  • Decorator Class
class CountCalls:
def __init__(self, func):
self.func = func
self.num_calls = 0

def __call__(self, *args, **kwargs):
self.num_calls += 1
print(f"This is executed {self.num_calls} times")
return self.func(*args, **kwargs)

@CountCalls
def say_hello():
print("Hello!")

say_hello()
say_hello()
  • Multiple decorators
def uppercase_decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper

def greeting_decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"Greetings! {result}"
return wrapper

@uppercase_decorator
@greeting_decorator
def greet(name):
return f"Hello, {name}!"

# Call the decorated function
result = greet("Alice")
print(result) # Output: "GREETINGS! HELLO, ALICE!"

The order of decorators matters; in this case, greeting_decorator is applied first, followed by uppercase_decorator.

  • Decorator with logging.
import functools
import logging

def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logging.info(f"Running {func.__name__} with args: {args}, kwargs: {kwargs}")
return func(*args, **kwargs)
return wrapper

@logged
def add(x, y):
return x + y

print(add(2, 3))

When you create a decorator and apply it to a function, the original function’s metadata such as its name, docstring, module, annotations, etc., get replaced by those of the wrapper function created by the decorator. This can cause issues, especially when debugging or using introspection tools.

functools.wraps helps solve this problem by copying over the relevant metadata from the original function to the wrapper function. It updates the attributes of the wrapper function (the function returned by the decorator) to match those of the original function being decorated. This is particularly useful for maintaining metadata and documentation. It ensures that attributes such as __name__, __doc__, and others are correctly propagated to the wrapper function, preserving the identity and documentation of the original function.

Now look at the below code:

import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("Do something before calling the function")
result = func(*args, **kwargs)
print("Do something after calling the function")
return result return wrapper

The output of this function will be —

  • my_function
  • This is the docstring of my_function

However, without the @functools.wraps(func), the output will be —

  • wrapper
  • None

Benefits of decorators

There are many benefits of using decorators including:

  • Code Reusability: Imagine writing common functionality like logging, caching, or authentication for every function that needs it. Decorators eliminate this redundancy by allowing you to define this functionality once and then apply it to any function you want simply by using the decorator syntax. This saves you time and effort, keeps your code DRY (Don’t Repeat Yourself), and improves maintainability.
  • Code Readability and Organization
  • Extending functionality without modifying original code
  • Dynamically modifying function behavior
  • Implementing design patterns
  • Unit Testing: Testing code that uses decorators can be simplified by isolating the decorator logic and testing it independently from the decorated functions. This improves the overall testability of your code and ensures that both the core functionality and the decorator’s enhancements are covered by unit tests.

@property Decorator

The @property decorator in Python is a way to create “pseudo-attributes” for methods in Python classes. Once defined, you can then access those methods like regular attributes, making code more readable and intuitive.

Let’s elaborate this with an example:

class Circle:
def __init__(self, radius):
self._radius = radius # Private attribute to store radius

@property
def radius(self):
"""Gets the radius of the circle."""
return self._radius

@radius.setter
def radius(self, new_radius):
"""Sets the radius of the circle, ensuring it's positive."""
if new_radius > 0:
self._radius = new_radius
else:
raise ValueError("Radius must be positive.")

@radius.deleter
def radius(self):
"""Deletes the radius of the circle, raising a warning."""
print("Warning: Deleting the radius of the circle is not recommended.")
del self._radius

@property
def area(self):
"""Calculates the area of the circle."""
return 3.14159 * self._radius**2

Additional features:

  • Setters: Use @<property_name>.setter to define how a property's value can be changed.
  • Deleters: Use @<property_name>.deleter to define how a property can be deleted.

Now, let’s see how can we use the above properties.

circle = Circle(5)  # Creates a circle with radius 5

# Get the radius
radius = circle.radius # Returns 5

# Get the area
area = circle.area # Returns 78.53975 (approximately)

print("The circle's radius is", radius)
print("The circle's area is", area)

Setting a property value (using the setter):

# Update the radius
circle.radius = 10 # Sets the radius to 10

# Get the updated radius and area
new_radius = circle.radius # Returns 10
new_area = circle.area # Returns 314.159 (approximately)

print("The new radius is", new_radius)
print("The new area is", new_area)

Deleting a property value (using the deleter):

del circle.radius

Key points to remember:

  • Accessing properties: Use the same syntax as accessing regular attributes (e.g., object.property_name).
  • Setting properties: Assign a value to the property like you would to an attribute (e.g., object.property_name = new_value). This triggers the setter method if defined.
  • Read-only properties: If a setter is not defined, the property is read-only.
  • Custom logic: The actual behavior of property access and modification is defined within the @property methods, allowing for flexibility and control.

@dataclass Decorator

The @dataclass decorator was introduced in Python 3.7 to simplify the creation of classes which are primarily used to store data. It automatically generates common methods like __init__(), __repr__(), and __eq__(), saving you time and code.

Let me explain with an example:

from dataclasses import dataclass

@dataclass
class Point:
x: int
y: int

p = Point(10, 20)
print(p) # Output: Point(x=10, y=20)

print(p.x) # Output: 10
print(p.y) # Output: 20

p.x = 11
print(p) # Output: Point(x=11, y=20)

Key features:

  • Automatic creates the __init__() constructor with arguments for each field.
  • Uses type annotations to specify field types.
  • Automatically generates __repr__() method to provide a clear representation of the object.
from dataclasses import dataclass

@dataclass
class Point:
x: int
y: int

# No need to write __repr__() explicitly! It's automatically generated.

p = Point(10, 20)

# This is the automatically generated __repr__() function in action:
print(repr(p)) # Output: Point(x=10, y=20)

# You can also access it directly from the class:
print(Point.__repr__(p)) # Output: Point(x=10, y=20)
  • Automatically generates __eq__() method which enables comparison based on field values.
from dataclasses import dataclass

@dataclass
class Point:
x: int
y: int

# Example of __eq__() function in action
p1 = Point(10, 20)
p2 = Point(10, 20)
p3 = Point(5, 15)

print(p1 == p2) # Output: True
print(p1 == p3) # Output: False

Additional features:

  • order=True enables sorting based on field order.
@dataclass(order=True)
class Employee:
name: str
salary: int

# Sorting based on salary
employees = [Employee("Alice", 50000), Employee("Bob", 60000)]
sorted_employees = sorted(employees) # Sorts by salary
  • frozen=True makes instances immutable.
@dataclass(frozen=True)
class ImmutablePoint:
x: int
y: int

# Cannot modify fields after creation
p = ImmutablePoint(10, 20)
p.x = 30 # Raises an error
  • slots=True optimizes memory usage by using slots for attributes.

field() method of dataclasses module:

The field method of dataclasses module is a function from the dataclasses module that provides fine-grained control over individual fields within a dataclass. It allows you to customize the behavior of fields, including setting defaults, controlling initialization, and influencing comparisons.

Syntax:

field(default=None, default_factory=None, init=True, repr=True, hash=None, compare=True, metadata=None)

Key Parameters:

  • default: Sets a default value for the field if not provided during object creation.
  • default_factory: Specifies a function that produces a default value for the field each time a new object is created.
  • init: Determines whether the field is included in the generated __init__() method (default True).
  • repr: Controls whether the field is included in the string representation (__repr__()) of the object (default True).
  • hash: Specifies whether the field is included in the hash calculation (default True, unless compare=False).
  • compare: Indicates whether the field is considered in comparisons (__eq__() and __ne__()) (default True).
  • metadata: Allows attaching arbitrary metadata to the field for custom processing.

Examples:

  • Setting Default Values:
@dataclass
class Person:
name: str
age: int = field(default=25) # Default age of 25
  • Using a Default Factory:
import datetime

@dataclass
class Appointment:
date: datetime.date = field(default_factory=datetime.date.today)
  • Excluding Fields from Initialization and Representation:
@dataclass
class Book:
title: str
author: str
internal_id: int = field(init=False, repr=False)
@dataclass
class Rectangle:
width: int
height: int
area: int = field(init=False) # Exclude area from __init__()

def __post_init__(self):
self.area = self.width * self.height # Calculate area after initialization

# Creating a Rectangle object
rect = Rectangle(width=5, height=10)

print(rect) # Output: Rectangle(width=5, height=10)
print(rect.area) # Output: 50
from dataclasses import dataclass

@dataclass
class User:
username: str
email: str
password: str = field(repr=False) # Exclude password from string representation

user = User("alice", "alice@example.com", "supersecretpassword")

print(user) # Output: User(username='alice', email='alice@example.com')

repr=False only affects the string representation, not the internal state of the object. The password is still accessible through attribute access (e.g., user.password).

  • Excluding Fields from Comparisons:
@dataclass
class Coordinates:
x: int
y: int
display_order: int = field(compare=False)
  • Using metadata to perform validations.
from dataclasses import dataclass, field

@dataclass
class Product:
name: str
price: float
category: str = field(metadata={"validator": "is_valid_category"})

def __post_init__(self):
self.is_valid_category()

# Validator function
def is_valid_category(self):
valid_categories = ["apparel", "electronics", "household"]
if self.category not in valid_categories:
raise ValueError(f"Invalid category: {self.category}")


# Calling the validator explicitly
product = Product("T-shirt", 19.99, "apparel") # This will not fail
product = Product("T-shirt", 19.99, "furniture") # This will fail

Conclusion

Congratulations! Hope you learnt something new today. See you on Day 7.

Happy coding!

--

--

Dipan Saha

Cloud Architect (Certified GCP Professional Architect & Snowflake Core Pro)