Exploring Python Decorators: Part I

Srinaveen Desu
6 min readJul 31, 2020
source: https://wall.alphacoders.com/big.php?i=112487

Decorators have always played a major part in python applications. While they seem to be a bit difficult to understand for newbies, they are pretty simple when we try to look at them the Python way. I would like to give a try on how we can look at them in the Python way. While this article primarily concentrates on different primitive ways we can implement python decorators, I would love to write more on how we interpret them in the Python world in another article.

In Python, everything is an object. Literally, everything in python is an object. This is what is taken advantage of in the decorators. Well, that was enough of story, let's dive into code and understand more on this.

def demo():
print('This is demo function')

print(demo) # prints <function demo at 0x11008f680>
demo() # This is demo function

d = demo
print(d) # prints <function demo at 0x11008f680>
d() # This is demo function
var = 1
print(type(var)) # <class 'int'>
print(type(d)) # <class 'function'>

the variable d is just like any another variable (val ). The variable var can be passed in as arguments to functions, can be used in assignments, and much more.

def new_demo(arg):
print(arg) # 1
arg() # 2

new_demo(d) # calling the function with variable
>>>
<function demo at 0x107f3a680>
This is demo function
new_demo(var) # calling the function with variable
>>>
1
TypeError: 'int' object is not callable

So what is happening in the above calls to the function new_demo ?

In the first call to new_demo we are passing in a variable d that we created earlier. If you remember, this variable was holding the address of the object of function demo . In the function new_demo , the first is a print statement ( #1), and followed by that we are issuing a call on the argument.

Since the call new_demo(d) passed in the variable of the object holding the function we were able to make a call on the function argument ( #2).

In the second call new_demo(var) , since var was a variable holding an integer object, the first print statement (# 1 ) worked as expected while making a call (# 2 ) on integer object throws an error.

Well, this is all that’s there in decorators. With little code changes, we could create a wrapper around a function to make certain that, some things are wrapped and handled before the actual function call is made.

Before we write some actual decorators lets take a use-case and try to understand how we can solve the use-case using a decorator. The problem statement is that we need to make sure that every call to a function is always licensed. Licensed here means whenever the user needs to make a call to function he must be having a License key(string) which will be validated and only then he would be able to call the function. To keep things simple I would not write lines and lines code but just the basic necessary code blocks to solve the use-case.

def decor(func):
def wrapper(*args, **dkwargs):
if dkwargs['license'] == 'userlicense':
return func(*args, **dkwargs)
else:
raise ModuleNotFoundError('Module not found')
return wrapper

@decor
def user1(*args, **dkwargs):
print('User function called', args, dkwargs)


user1(license='userlicense') # 1
# User function called () {'license': 'userlicense'}
user1(license='developerlicense') # 2

# ModuleNotFoundError: Module not found

In the above decorator function, we have validated the user data and if the license provided was userlicense the function was called. However, in the case where a different key was provided, we got the ModuleNotFoundError. Thus, we were able to control the behavior of the user call with the decorator using the wrapper.

Now, what exactly is happening here. If we decode the above #1 and #2 it would expand to following

myfunction = decor(user1)
myfunction(license='userlicense')
# User function called () {'license': 'userlicense'}myfunction = decor(user1)
myfunction(license='developerlicense')
# ModuleNotFoundError: Module not found

We are calling the decor function passing the user1 function object as its argument. The decor function returns the wrapper function object which in turn validates the license and calls the user function accordingly.

print(myfunction)<function decor.<locals>.wrapper at 0x10b657f80>

As we see in the above output, we get the wrapper function which does wrapping on each and every user function and thus solving our use-case. We could either use the @ notation of decorator or go with the more direct approach ( myfunction ).

While this was pretty straight forward, Let’s check another approach using decorators giving us more flexibility to control user functions.

def outer_decor(license=None):
def decor(func):
def wrapper(*args, **dkwargs):
if license == 'userlicense':
return func(*args, **dkwargs)
else:
raise ModuleNotFoundError('Module not found')
return wrapper
return decor

@outer_decor(license='userlicense') # 1
def user1(*args, **dkwargs):
print('User function called', args, dkwargs)


user1()

# User function called () {}

@outer_decor(license='developerlicense') # 2
def user2(*args, **dkwargs):
print('User function called', args, dkwargs)

user2()

# ModuleNotFoundError: Module not found

We have modified the decorator to accept arguments. Thus, this makes sure that the caller would not have to explicitly send license details in his call and thereby adding an abstraction layer between the caller and system logic. When a user calls the user1 function, the decorator is called passing in the userlicense as the license parameter. This is validated against the wrapper test and call to function is completed.

Note: user1 is tightly coupled with userlicense and user2 with developerlicense . Calls to these functions can be made only when the corresponding licenses are available.

We can now take a look at what happens behind the scenes when parametrized decorators are used.

outer_dec = outer_decor(license='userlicense')
wrap = outer_dec(user1)
wrap()
# User function called () {}

The first call to outer_decor returns an object of function decor to which we are passing an object of function user1 which in turn returns the wrapper function which validates against the license data. Finally, we call the returned object to make call to the user1 function.

Was that too complex to digest? Just read the above paragraph couple of times and it would not be as complex as you might have thought in the first read.

print(outer_dec)# <function outer_decor.<locals>.decor at 0x10938c7a0>print(wrap)# <function outer_decor.<locals>.decor.<locals>.wrapper at 0x1093a6440>

We could see what each returned object from the function holds and thus unveils each call.

If you have got his far, I am pretty sure you would want to do the same using class decorators as well. The class decorators use the dunder method __call__ for making the class a callable. As much I would like to go into the details of how things unwrap behind scenes, just to keep this article short, I would cover them in a future article.

class Decor:

def __init__(self, func):
self.func = func

def __call__(self, *args, **kwargs):
if kwargs['license'] == 'userlicense':
return self.func(*args, **kwargs)
else:
raise ModuleNotFoundError('Module not found')


@Decor
def user1(*args, **dkwargs):
print('User function called', args, dkwargs)


user1(license='userlicense') # 1
# User function called () {'license': 'userlicense'}

user1(license='developerlicense') # 2

# ModuleNotFoundError: Module not found

As we saw earlier, we are seeing similar results. The call flow is similar to what we had seen earlier. The class Decor gets initiated passing object of function user1 which is instantiated as an instance variable. This is used in other calls for further reference to user functions. Let's see how the calls are unfolded when decorators are created using classes.

dec = Decor(user1)
dec(license='userlicense')
# User function called () {'license': 'userlicense'}

The class object initiates by creating an instance attribute which holdsuser1 function object. Since we used the dunder method to make the class a callable we are passing the license details to function which is then validated by wrapper function.

Another approach where we can add an abstraction layer between user calls and validation logic using decorators built with classes.

class Decor:

def __init__(self, license=None):
self.license = license

def __call__(self, func):
def wrapper(*args, **kwargs):
if self.license == 'userlicense':
return func(*args, **kwargs)
else:
raise ModuleNotFoundError('Module not found')
return wrapper

@Decor(license='userlicense')
def user1(*args, **dkwargs):
print('User function called', args, dkwargs)

@Decor(license='developerlicense')
def user2(*args, **dkwargs):
print('User function called', args, dkwargs)


user1()
# User function called () {}

user2()

# ModuleNotFoundError: Module not found

And we have the abstraction layer, where we associate license to each user function thus enabling calls to the right user only. If we don’t want to use a decorator and use the direct approach we would have to do something like this.

dec = Decor(license='userlicense')
wrap = dec(user1)
wrap()
# User function called () {}

Class Decor is instantiated with license variable which returns an object that is callable. We pass in the function object user1 to the callable object which returns the wrapper object of function. Finally, we make the call to the user function.

While there are number of ways to solve the use-case that we had taken into account, using decorators this is how we would solve the problem. This is not all of it. We could take a number of other things into account during creation of decorators, but this should be a good jump start. I will be writing another article where we explore more on decorators.

--

--