The Ultimate Guide to Python Decorators, Part III: Decorators with Arguments

Posted by
on under

This is the third part of my in-depth tutorial on Python decorators. In parts I and II I showed you how to create some useful decorators, but to keep things simple none of the decorator examples you've seen so far accept any arguments. In this installment you will learn how to write decorators that take custom arguments, like any normal Python function.

For your reference, here is a list of all the posts in this series:

Are Decorator Arguments Needed?

While there are many valid use cases that can be solved with the decorators that you learned in the previous parts of this series, there are some situations in which arguments come in handy. Consider the famous app.route decorator from Flask, as an example:

@app.route('/foo')
def foo():
    return 'this is the foo route'

The function of this decorator is to register the decorated function as a handler for a Flask route. The problem is that in Flask you can define many routes, each associated with a different URL, so somehow you need to tell the decorator which URL you are registering a handler for. Without being able to pass the URL argument to the decorator there would be no way to make this route decorator work.

Implementing Decorator Arguments

Unfortunately adding arguments to a decorator is far from trivial. Let's revisit the standard decorator structure we have used so far:

def my_decorator(f):
    def wrapped(*args, **kwargs):
        print('before function')
        response = f(*args, **kwargs)
        print('after function')
        return response
    print('decorating', f)
    return wrapped

@my_decorator
def my_function(a, b):
    print('in function')
    return a + b

Here you can see that my_decorator takes no arguments when it is used to decorate a function, but the implementation of this decorator does take the f argument, through which Python passes a reference to the decorated function. You may expect that decorator arguments are somehow passed into the function along with this f argument, but sadly Python always passes the decorated function as a single argument to the decorator function. To make matters more confusing, you can also see that the wrapped() inner function has "catch-all" arguments which are passed directly into the decorated function and should never be mixed with arguments meant for the decorator. So there is really no place where decorator arguments can be added.

Let's call the decorators that we've seen until now "standard decorators". We've seen that a standard decorator is a function that takes the decorated function as an argument and returns another function that takes its place. Using the above my_function() example, the work that the standard decorator does can be described also in Python as:

my_function = my_decorator(my_function)

This is important to keep in mind, because decorators with arguments are built on top of standard decorators. A decorator with arguments is defined as a function that returns a standard decorator. This is actually very hard to think about, so I'm going to show you how to extend the previous decorator to accept one argument:

def my_decorator(arg):
    def inner_decorator(f):
        def wrapped(*args, **kwargs):
            print('before function')
            response = f(*args, **kwargs)
            print('after function')
            return response
        print('decorating', f, 'with argument', arg)
        return wrapped
    return inner_decorator

The inner_decorator function in this example is almost identical to the my_decorator() function in the previous one. The only difference is that the print statement now also prints arg. The new my_decorator() function accepts arg as an argument and then returns a standard decorator defined as an inner function, so now we have three layers of functions inside functions in this implementation. The decorator arguments are accessible to the inner decorator through a closure, exactly like how the wrapped() inner function can access f. And since closures extend to all the levels of inner functions, arg is also accessible from within wrapped() if necessary.

Here is an example usage for this decorator:

@my_decorator('foo')
def my_function(a, b):
    print('in function')
    return a + b

The equivalent Python expression for this new decorator is:

my_function = my_decorator('foo')(my_function)

Example #1: Flask's Route Decorator

Let's write a simple version of Flask's route decorator:

route_map = {}

def route(url):
    def inner_decorator(f):
        route_map[url] = f
        return f
    return inner_decorator

This implementation accumulates route to function mappings in a global route_map dictionary. Since this is a function registration decorator, there is no need to wrap the decorated function, so the inner decorator just updates the route dictionary and then returns the f decorated function unmodified.

An example usage of this decorator could be:

@route('/')
def index():
    pass

@route('/users')
def get_users():
    pass

If you run the above example and then print route_map, this is what you would get:

{
    '/': <function index at 0x7a9bc16a8cb0>,
    '/users': <function get_users at 0x7a9bc16a8dd0>
}

In reality, Flask's route decorator is much more complex, in ways that fall out of the scope of this article. To make this example a bit more realistic we can add the methods optional argument, which also records HTTP methods in the route map:

route_map = {}

def route(url, methods=['GET']):
    def inner_decorator(f):
        if url not in route_map:
            route_map[url] = {}
        for method in methods:
            route_map[url][method] = f 
        return f
    return inner_decorator

With this improved version you can build more advanced mappings such as this:

@route('/')
def index():
    pass

@route('/users', methods=['GET', 'POST'])
def get_users():
    pass

@route('/users', methods=['DELETE'])
def delete_users():
    pass

The route_map for the above example would be:

{
    '/': {
        'GET': <function index at 0x7a9bc16a8680>
    },
    '/users': {
        'GET': <function get_users at 0x7a9bc16a84d0>,
        'POST': <function get_users at 0x7a9bc16a84d0>,
        'DELETE': <function delete_users at 0x7a9bc16a8a70>
    }
}

Example #2: Permission Checking

A common pattern in web applications is to check wether a client request has permission to perform the action it is requesting. These checks involve obtaining the value of a header or cookie included in the request to determine the identity of the client, and then once the client is known an application specific method is used to determine if permission can be granted or not.

Since the actual permission check is application dependent, here I'm going to show you a more generic example that just gives permission to execute the request based on the value of a header. Let's say, for example, that we want to only allow requests made by a specific user agent to go through, while all other user agents are rejected:

from flask import Flask, request, abort

app = Flask(__name__)

def only_user_agent(user_agent):
    def inner_decorator(f):
        def wrapped(*args, **kwargs):
            if user_agent not in request.user_agent.string.lower():
                abort(404)
            return f(*args, **kwargs)
        return wrapped
    return inner_decorator

@app.route('/')
@only_user_agent('curl')
def index():
    return 'Hello Curl!'

If you run the above application and then navigate with your browser to http://localhost:5000 you will get a "Not Found" error. But if you access the same URL with curl from a terminal, the request is allowed to execute:

(venv) $ curl http://localhost:5000/
Hello Curl!
Become a Patron!

Hello, and thank you for visiting my blog! If you enjoyed this article, please consider supporting my work on this blog on Patreon!

13 comments
  • #1 Domas Zelionis said

    As i never had a time to explore it deep my self.
    Really appreciate it!
    Thanks Miguel,
    Best regards,
    Domas Zelionis

  • #2 Greatbahram said

    Hello Miguel,

    I just wanted to say this series is awesome!

    And I like the way you explain things in the simplest manner and also pragmatic way by covering different aspects of flask things!

    Rock on!

  • #3 Jim said

    Great explanations. Thanks!

  • #4 Bernardo Gomes de Abreu said

    Good explanation !! Keep sharing the knowledge!!

  • #5 Me said

    Why no mention of functools.wrap in this article series? It removes a lot of the boiler plate

  • #6 Miguel Grinberg said

    @Me: because this series isn't complete yet. There are at least two more chapters to be written, maybe more. Also, functools.wraps does not remove any boilerplate, it just updates the function name and docstring that are lost after a decorator is applied.

  • #7 Chuck said

    Thank you for writing this, it is extremely helpful. I was stumped as to why passing along a single argument was not working and this explained it very well.

  • #8 Mansour said

    Hi Man
    Thank you for your percise and clear article

  • #9 Kuo said

    This is a very well-written tutorial! One suggestion I have is including adding "@functools.wraps(f)" to the "inner_decorator" function. It is relatively quick and simple while keeping all docstrings from the original function.

  • #10 Shashi Shekhar said

    Miguel, my brother you are a god send !!

  • #11 that guy said

    Thanks for a nice article.

    I think it is worth mentioning that in the Example #2 the order of decorators is crucial.
    Essentially app.route should be applied last (appear on top), otherwise it won't do anything.

  • #12 Ofer Nave said

    Great article. Only complaint (from my POV) is that it didn't explain the final and most advanced case to me - decorators that may take arguments but don't have to. The two examples that come to mind are Click (the CLI builder from the Flask project) and Dramatiq (task system, competitor to Celery).

    @click.command
    def foo():
    ....

    @click.command(name="blah")
    def bar():
    ...

    @dramatiq.actor
    def foo():
    ...

    @dramatic.actor(delay=50000)
    def bar():
    ....

    Since you mentioned having two more chapters planned, I'm guessing it will be included there.

  • #13 Miguel Grinberg said

    @Ofer: yeah, I never got around to write the rest of this series. Decorators that support optional arguments have to be implemented with a hack. If the decorator function is called with a single argument and this argument is a callable, then the decorator assumes it was called without arguments. In any other case it assumes it was called with arguments.

Leave a Comment