Understanding Python Decorator and Its Usage

byAaditya Chapagain
Machine Learning python coding decorator callbacks developer python skill

Upskill your python using awesome decorators

You might have seen python code that has @<function_or_class> written immediately above a function or Class. For Example, @staticmethod right above static method or @classmethod right above class method. Those are actually Python decorators. Decorators allow an existing function to be extended without changing the source code.

In this blog, we will comprehend a decorator structure, examine advanced behaviours including drawbacks and ways to overcome it, nested decorators, stacking decorators and eventually some practical applications, develop our own multi-purpose decorators.

Table of contents

  1. Writing Decorator
  2. Advanced behaviours of Decorators
    1. Retain metadata of wrapped function
    2. Decorators that accept arguments
    3. Stacking decorators
  3. Usage of Decorators
    1. Measuring execution time of functions
    2. Debug with logging
    3. Registering Plugins
    4. Creating a singleton class
  4. REFERENCES

Writing Decorator

Decorators are often referred to as function wrappers because they accept a function as a paramter and return a modified version of the function that has additional features or capabilites.


def simple_decorator(func):
  def wrapper(*args, **kwargs):
    # do something before function execution
    result = func (*args, **kwargs)
    # do something after function execution
    return result
  return wrapper

we can see from the construct above that line 4 is where the function really executes, but we can change what happens before to, during and even after a function execution. Althouh decorators may alter a function's input, output, or behaviour, it is preferable to build them in a way that does not make the function they are wrapping less understandable.

Decorators are best used to add common behaviour to multiple functions without modifying every function manually

Advanced behaviours of Decorators

Retain metadata of wrapped function

One major drawback of using decorators is that the metadata of function will be destroyed by decorator when actually calling that function in code, because we are returning a wrapper function in place of the original function in the code sample from the previous section, all decorated functions will have their __name__ and signature information changed to wrapper's.


@simple_decorator
def func_add(a, b):
  return a + b

print(func_add.__name__)
# wrapper

Technically, this wouldn't impact how the fucntion or decorator was meant to be used, but it's still advisable to avoid any unexpected outcomes when using a decorator. This can be accomplished quickly by decorating the wrapper function with the @wraps decorator, as illustrated below.The decorator can still be used in the same way, but now the wrapped function's metadata won't be changed.


from functools import wraps

def sample_decorator(func):
  @wraps(func)
  def wrapper(*args, **kwargs):
    # do something before function exec
    result = func(*args, **kwargs)
    # do something after function exec
    return result
  return wrapper

Decorators that accept arguments

Under the hood we all know that decorators are just a function wrapper. So, it is possible for decorator to receive arguments to make make this happen , we just need to slight change in coding our decorator. We will get decorator that will take arguments, If only we just wrap the existing decorator with another function with arguments we wanna pass to the decorator. Easy right !.

Example below I have a debug_decorator that takes parameters and returns a decorator decorator that wraps thea original function in a new one. The several nested function in this can make it appear fairly confusing at first but first creating the original decorator before wrapping it to take argumeants will make it easy to code and understand.


from functools import wraps

debug_mode = True


def debug_decorator(debug_mode):
    """Example: Passing arguments to a decorator"""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if debug_mode:
                print(f"Function called: {func.__name__}")
            result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator


@debug_decorator(debug_mode)
def func_add(a, b):
    return a + b


func_add(1, 2)
# Function called: func_add
# 3

Stacking decorators

Decorators, as previously indicated, permit the extension of current functionalities. To add extra extensions, it is possible to stack several decorators over a function. The decorator will be piled in the same order as tahe order of execution.

One thing to remember is that if time-sensitive decorators are layered, they should be added at last. So that they can accurately reflect the execution of time without being influeced by other decorators. Decorators that measure the execution of time of a function, for instance, should be the last to be executed.

Now, that we are familiar with a decorator's structure and sophisticated behaviours, we can explore their actual use!

Usage of Decorators

Measuring execution time of functions

The timer decorator can measure the execution time of the wrapped function by recording the start time and end time of the function execution and printing the results to the console.

In the code snippet below, we measure the start_time and end_time before and after function execution.

import time

from functools import wraps

def timer(func):
    """Example: Measure execution time of function"""
    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"Execution time: {round(end_time - start_time, 4)}")
        return result
    return wrapper


@timer
def func_add(a, b):
    time.sleep(2)
    return a + b


func_add(1, 2)
# Execution time: 2.0064

Debug with logging

The logging decorator can be used to log information to a console or log file and is useful for debugging. Below we'll use the logging python package to perform logging.


import logging

from datetime import datetime
from functools import wraps

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def logging(func):
    """Example: Logging with decorator"""

    @wraps(func)
    def wrapper(*args, **kwargs):
        log_time = datetime.today().strftime("%Y-%m-%d %H:%M:%S")
        logger.info(f"{log_time}: {func.__name__} called")
        result = func(*args, **kwargs)
        return result
    return wrapper

Registering Plugins

Decorators don’t have to wrap the function they’re decorating. They can also simply register that a function exists and return it unwrapped. This can be used, for instance, to create a light-weight plug-in architecture:

import random
PLUGINS = dict()

def register(func):
    """Register a function as a plug-in"""
    PLUGINS[func.__name__] = func
    return func

@register
def say_hello(name):
    return f"Hello {name}"

@register
def be_awesome(name):
    return f"Yo {name}, together we are the awesomest!"

def randomly_greet(name):
    greeter, greeter_func = random.choice(list(PLUGINS.items()))
    print(f"Using {greeter!r}")
    return greeter_func(name)

The @register decorator simply stores a reference to the decorated function in the global PLUGINS dict. Note that you do not have to write an inner function or use @functools.wraps in this example because you are returning the original function unmodified.

The randomly_greet() function randomly chooses one of the registered functions to use. Note that the PLUGINS dictionary already contains references to each function object that is registered as a plugin:

>>> PLUGINS
{'say_hello': <function say_hello at 0x7f768eae6730>,
 'be_awesome': <function be_awesome at 0x7f768eae67b8>}

>>> randomly_greet("Alice")
Using 'say_hello'
'Hello Alice'

The main benefit of this simple plugin architecture is that you do not need to maintain a list of which plugins exist. That list is created when the plugins register themselves. This makes it trivial to add a new plugin: just define the function and decorate it with @register.

If you are familiar with globals() in Python, you might see some similarities to how the plugin architecture works. globals() gives access to all global variables in the current scope, including your plugins:


>>> globals()
{..., # Lots of variables not shown here.
 'say_hello': <function say_hello at 0x7f768eae6730>,
 'be_awesome': <function be_awesome at 0x7f768eae67b8>,
 'randomly_greet': <function randomly_greet at 0x7f768eae6840>}

Using the @register decorator, you can create your own curated list of interesting variables, effectively hand-picking some functions from globals().

Creating a singleton class

Singleton class is a design pattern that restricts the instantiation of a class and ensures that only one instance of the class exists.

Singleton class is helpful when there is a cap on the number of concurrent users who can use a shared resource or when there is only one point of access to a resource. In python singleton class can be created just using decorator.

Single instantiation can be guaranteed by specifically coding singleton classes. But, utilising decorators is a clever method to reuse the code for several classes if there are several singleton classes.

from functools import wraps


def singleton(cls):
    """Example: Create singleton class with decorator"""
    instances = {}

    @wraps(cls)
    def wrapper(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return wrapper


@singleton
class SampleClass:
    def __init__(self):
        pass


singleton_class = SampleClass()
singleton_class2 = SampleClass()
print(singleton_class == singleton_class2)
# True

Hope, you now have a solid understanding of decorators fundamentals, practical advice and real-world examples. Other applications include the use of decorator for caching, memory management, and timeout precedures. These decorators are more sophisticated, thus using built-in decorators from python or decorators from 3^rd party python packages is preferable to creating them from scratch.

Hope In future , I will come up with new blog posts explaining all the really really complicated python decorators.

REFERENCES

2024 Aaditya Chapagain
All Rights Reserved