python decorators

Enabling Code Reuse with Decorators – Part 1

In this new post, we are going to talk about Python decorators and how to write custom decorators for your python apps.

Starting today, I am creating a series of in-depth posts on decorators,  my first post would be a short introduction to decorators, we would understand how decorators work in Python and learn how to implement decorators that apply to functions.

OUTLINE OF DECORATOR SERIES
i. Introduction to decorators and decorating python functions (this article).
ii.Writing class decorators and decorating class methods.
iii. Decorators that take arguments.

 

Before we dive into decorators, let us refresh our knowledge of nested functions and closures.

  • What are nested functions?
  • What are  non-local variables?
  • What are closures?

 

Nested Function

A nested function is simply a function within another function.
To define a nested function, just initialize another function within a function using def.

NON-LOCAL VARIABLES

A non-local variable is a variable that works inside nested functions, here the variable does not belong to the inner function but the outer function.

def outerFunction(num):
    def innerFunction():
        return num + 2
    return innerFunction()

print(outerFunction(2))
4

In the above code, the num is the local variable.
The innerFunction is only available and nested within the outerFunction, if you try to call the innerFunction alone you get the error displayed below innerFunction()

NameError: name 'innerFunction' is not defined

CLOSURES

When a function is nested inside another function then closure is created.
A closure simply causes the inner function to remember the state of its environment when called which means the inner function will have access to variables and parameters of outer function even after the outer function is returned.

Important Points to Remember about Closures

  • There should be a nested function i.e. function inside a function.
  • The inner function must refer to a non-local variable or the local variable of the outer function.
  • The outer function must return the inner function.
def outer(name,email):
    
    def inner():
        #using non-local variable email and name
        return f"Hello! {name}, your email address is {email}"
    #return inner function
    return inner 


func = outer('Mac','mac@gmail.com')
func()
'Hello! Mac, your email address is mac@gmail.com'

Decorator

A decorator is a function that accepts a function as input and returns a new function as output.

If we had a function called hello and a decorator called timeit, then the following would decorate hello with timeit.

x = timeit(hello)

Python has a special syntax on how to decorate a function using the @ operator, so the code above could be written like this

@timeit
def hello():
    pass

The decorator @timeit simply receives a function and returns usually a different function.

def timeit(python_function):
    return python_function

THAT ASIDE, Python has inbuilt decorators such as @staticmethod, @classmethod, and @property that works in the same way.

class A:
    @classmethod
    def class_method(cls):
        pass
    @staticmethod
    def static_method():
        pass

I recently wrote an article on decoding the python @staticmethod and @classmethod, you can check it out here.

DECORATING FUNCTIONS.

It is very important to preserve the properties of the original function or function metadata while writing decorators and this could be achieved by applying the @wraps decorator from the functools library.

We are going to design 2 decorators.

  • A @timeit decorator that mimicks %timeit(ipython magic function), it would measure the time a function takes to execute and prints the duration.
  • @validate_data decorator that validates the user input(it checks if the mobile number is a valid Nigerian phone number) and raises an error if the input exceeds the required length.
LET’S get  started

1. @timeit decorator

import time 
from functools import wraps

def timeit(function_to_be_decorated):
    """A decorator that reports execution time """
    @wraps(function_to_be_decorated)
    def wrapper(*args,**kwargs):
        start = time.time()
        function_result = function_to_be_decorated(*args, **kwargs)
        end = time.time()
        time_spent = end - start
        print(f"time spent executing this code {time_spent}")
        return function_result
    return wrapper
        
        

@timeit
def calculate_interest(principal, time, rate):
    """simple code to calculate simple interest"""
    SI = principal *  time *rate
    return SI

calculate_interest(50006906, 12, 0.006)
time spent executing this code 4.5299530029296875e-06

3600497.232

Explaining the code above.

The code inside a decorator typically involves creating a new function that accepts any arguments using *args and **kwargs, as shown with the wrapper() function above.
Inside this function, you place a call to the original input function and return its result. However, we also placed our extra code timing.

start = time.time() 
end = time.time() 
time_spent = end - start

The newly created function wrapper is returned as a result and takes the place of the original function.
The use of *args and **kwargs is there to make sure that any input arguments can be accepted.
The use of the decorator @wraps(function_to_be_decoratedin the solution is important because it preserves

  • The original function properties such as name, docs, and annotations.
print(calculate_interest.__name__)
print(calculate_interest.__doc__)
calculate_interest
simple code to calculate simple interest
  • Makes the wrapped function available to you in the __wrapped__ attribute.
    #you could access the wrapped function directly by doing this:
    print(calculate_interest.__wrapped__(50006906, 12, 0.006))
    3600497.232
    
    

     

     

2. @validate_data  decorator

def validate_data(func):
    @wraps(func)
    def wrapper(*args, **kwargs): 
        data = func(*args, **kwargs) 
        if len(data) > 13:
            print("invalid phone number")
        else:
            return data
    return wrapper
                
                       

@validate_data
def input_mobile_number(state_code, mobile_no):
    if state_code == "NIG":
        mobile_no = mobile_no[1:]
        phone_no = "+234" + mobile_no
        return phone_no
    

 input_mobile_number("NIG","0812334456777888")   
invalid phone number

 

Importance of decorators

The use of decorators are much but the most useful cases are:

  • Enforcing access control and authentication, e.g. flask @login_required decorator.
from flask import Flask, g, request, redirect, url_for
import functools
app = Flask(__name__)

def login_required(func):
    """Make sure user is logged in before proceeding"""
    @functools.wraps(func)
    def wrapper_login_required(*args, **kwargs):
        if g.user is None:
            return redirect(url_for("login", next=request.url))
        return func(*args, **kwargs)
    return wrapper_login_required

@app.route("/edit_blogpost")
@login_required
def edit_blogpost():
    .........
  • Logging
  • Caching and Memoization.
  • Data Validation and Runtime checks.
  • Code Reuse (DRY principle).

 

In my next post, we look at creating class decorators and decorating class functions.

Thanks for reading, please share with your network, if you found this post helpful, comment, and share your thoughts in the comment section.

Don’t forget to subscribe to my newsletter.

Further Reading

 

 

 

4 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *