Forays Into AI

The only way to discover the limits of the possible is to go beyond them into the impossible. - Arthur C. Clarke

Python Decorators: Enhance Your Code with Function Wrappers

Python code Ever wished you could enhance your Python functions without modifying their core logic? Enter Python decorators (no, not the decorator pattern) – a powerful feature that allows you to modify or extend the behaviour of functions and classes with just a simple @ symbol. In this tutorial, we'll into decorators, how they can be useful to make your code more elegant, efficient, and maintainable.

In Python, functions are first-class objects. This means you can treat functions like any other object – assign them to variables, pass them as arguments, or return them from other functions.

Let's start with a simple example:

def product_name(name):
    return f"Product Name: {name}!"

# Assigning a function to a variable
say_name = product_name

# Using the function through the variable
print(say_name("Claude Sonnet"))  # Output: Product Name: Claude Sonnet!

Basic Decorator Syntax

At its core, a decorator is a function that takes another function as an argument and returns a new function that usually extends or modifies the behaviour of the original function. The @ symbol provides a clean, intuitive way to apply decorators.

Here's a simple decorator that wraps product name in an h2 heading:

def heading(func):
    def wrapper(name):
        result = func(name)
        return f"<h2>{result}</h2>"
    return wrapper

@heading
def product_name(name):
    return f"Product Name: {name}!"

print(product_name('Claude Sonnet'))  # Output: <h2>Product Name: Claude Sonnet!</h2>

In this example, heading is a function that takes another function (func) as an argument. It defines an inner function wrapper that calls the original function, wraps it in h2 tags, and returns it. The @heading syntax is equivalent to product_name = heading(product_name).

How Decorators Work

To understand decorators better, let's break down their execution flow:

  1. When Python encounters a decorated function, it first executes the decorator function.
  2. The decorator function typically defines and returns a new function.
  3. This wrapper function is then bound to the original function's name.
  4. When the decorated function is called, it's actually the wrapper function that gets executed.

Here's a visual representation of this flow:

       @decorator
       def original_func():
           # Implementation

       ↓

       original_func = decorator(original_func)

       ↓

       def wrapper():
           # Optionally do some pre-processing
           result = original_func()
           # Optionally do some post-processing
           return result

       original_func = wrapper

This mechanism allows decorators to perform actions before and after the decorated function runs, modify its arguments or return value, or even prevent it from running altogether e.g. if some conditions have not been met.

Creating Your Own Decorators

Now that we understand the basics, let's create a more practical decorator. We'll implement a timing decorator that measures how long a function takes to execute:

import time

def measure_time(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"{func.__name__} took {end_time - start_time:.5f} seconds to run.")
        return result
    return wrapper

@measure_time
def slow_function():
    time.sleep(2)
    print("Function execution complete.")

slow_function()

Note that this decorator uses *args and **kwargs to accept any number of arguments, making it more versatile. It measures the time before and after executing the function, prints the execution time, and returns the original result.

Here is another trivial one which decorates a function returning a csv string to return a dataframe instead.

import pandas as pd
from io import StringIO

def csv_to_dataframe(func):
    def wrapper(*args, **kwargs):
        csv_string = func(*args, **kwargs)
        if not isinstance(csv_string, str):
            raise ValueError("The decorated function must return a CSV string")
        
        # Convert CSV string to DataFrame
        df = pd.read_csv(StringIO(csv_string))
        return df
    
    return wrapper

@csv_to_dataframe
def get_csv_data():
    return """
name,age,city
Alice,28,New York
Bob,35,Cape Town
Charlie,42,London
"""

# Using the decorated function
result_df = get_csv_data()
print(result_df)

Decorators with Arguments

Decorators can also accept arguments, allowing for even more flexibility. Let's create a decorator that retries a function a specified number of times:

import time
import random

def retry(max_attempts=3, delay=1):
    def decorator(func):
        def wrapper(*args, **kwargs):
            attempts = 0
            while attempts < max_attempts:
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempts + 1} failed: {str(e)}")
                    attempts += 1
                    time.sleep(delay)
            raise Exception(f"Function failed after {max_attempts} attempts")
        return wrapper
    return decorator

@retry(max_attempts=3, delay=1)
def unreliable_function():
    if random.random() < 0.7:
        raise Exception("Random error occurred")
    return "Success!"

print(unreliable_function())

This decorator takes arguments max_attempts and delay, defining how many times to retry and how long to wait between attempts. Indeed decorators can be used to add robust error handling to your functions.

Class Decorators

Decorators are not limited to functions - they can also be applied to classes. This example demonstrates implementing the singleton pattern using a class decorator for a shared resource:

def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class DatabaseConnection:
    def __init__(self):
        print("Initialising database connection")

# Creating multiple instances
db1 = DatabaseConnection()
db2 = DatabaseConnection()

print(db1 is db2)  # Output: True

This decorator ensures that only one instance of the DatabaseConnection class is ever created, which can be useful for managing resources like database connections.

Built-in Decorators

Python provides several built-in decorators that you'll often encounter:

@property

The @property decorator allows you to define methods that can be accessed like attributes:

class BankAccount:
    def __init__(self, account_number, balance):
        self._account_number = account_number
        self._balance = balance

    @property
    def account_number(self):
        return self._account_number

    @property
    def balance(self):
        return self._balance

    @property
    def is_premium(self):
        return self._balance >= 10000


account = BankAccount("12345", 50000)

print(f"Account Number: {account.account_number}")   # Output: 12345
print(f"Current Balance: £{account.balance}")        # Output: £50000
print(f"Is Premium Account: {account.is_premium}")   # Output: True

@classmethod and @staticmethod

These decorators are used to define methods that don't require access to instance-specific data:

class MyClass:
    @classmethod
    def class_method(cls):
        print("This is a class method, strangely")

    @staticmethod
    def static_method():
        print("This is a static method")

MyClass.class_method()    # Output: This is a class method, strangely
MyClass.static_method()   # Output: This is a static method

Some Real-world Applications

Decorators have numerous practical applications:

  1. Logging: Add logging to functions without cluttering their code.
  2. Authentication: Check user permissions before allowing access to certain functions.
  3. Memoisation: Cache function results to improve performance.

Here's a simple memoisation example:

def memoise(func):
    cache = {}
    def wrapper(*args):
        if args in cache:
            return cache[args]
        result = func(*args)
        cache[args] = result
        return result
    return wrapper

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

print(fibonacci(100))  # Calculates quickly due to memoisation rather than recomputing at each turn

Or a simple FastAPI get route:

from fastapi import FastAPI

app = FastAPI()

@app.get("/greeting")
async def greeting():
    return {"message": "Hello World"}

Best Practices and Pitfalls

When working with decorators, keep these best practices in mind:

  1. Use functools.wraps to preserve function metadata:

    from functools import wraps
    
    def my_decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapper
    
    @my_decorator
    def do_something():
         print('doing something...')
    
    # Prints correct function name
    print(do_something.__name__) # Output: do_something
    
    # without the @wraps line, function name is lost
    print(do_something.__name__) # Output: wrapper
    
  2. Be cautious with mutable arguments in decorators or decorators that modify function signatures.

  3. Avoid excessive nesting of decorators, as it can make code hard to read.

  4. Remember that decorators are executed at function definition time, not at call time.

TaggedPythonProgrammingDecorators

Enumerations in Scala 2 vs Scala 3

In the ever-evolving world of programming languages, Scala 3 has made substantial improvements in the implementation of enumerations. This blog post will look into the differences between Scala 2 and Scala 3 enumerations, highlighting the enhancements and providing practical insights for developers.

Lazy Evaluation with Python Generators

Have you ever worked with large datasets in Python and found your program grinding to a halt due to memory constraints. This tutorial discusses lazy evaluation using Python generators.

Introduction to Lambda Functions

Lambda functions are a powerful tool for writing efficient Python code when used appropriately. This tutorial provides an overview of lambda functions in Python, covering the basic syntax, demonstrating how these anonymous functions are defined using the lambda keyword.