Decorators#

Function decorators in Python are a powerful and expressive feature for modifying the behavior of functions or methods. They allow you to “decorate” a function without changing its structure, enhancing its functionality or altering its behavior.

At the core, a decorator is a function that takes another function as an argument and extends its behavior without explicitly modifying it. This is achieved by wrapping the original function inside a new function.

A Simple Example#

In the following example, we have a simple decorator that prints a message before and after the “decorated” function is called.

First, we show explicitly wrapping a function by passing a function to the decorator.

 1def my_decorator(func):
 2    def wrapper():
 3        print("Before the function is called.")
 4        func()
 5        print("After the function is called.")
 6    return wrapper
 7
 8def say_hello():
 9    print("Hello!")
10
11say_hello = my_decorator(say_hello)
12say_hello()
Before the function is called.
Hello!
After the function is called.

Note: The names used for the func parameter and the inner function (e.g., wrapper) are not fixed - you can choose different names, you just need to follow the pattern.

More typically, we will use the @ symbol to decorate a function:

1@my_decorator
2def hello_world():
3    print("Hello World!")
4
5hello_world()
Before the function is called.
Hello World!
After the function is called.

In this case, the functionality of my_decorator will always be performed whenever the function hello_world() is called

Decorating Functions With Arguments#

To decorate functions that accept arguments, modify the wrapper function to accept arguments. As we do not necessarily know the number of arguments, we’ll use *args and **kwargs to pack/unpack positional and named parameters.

 1def my_decorator(func):
 2    def wrapper(*args, **kwargs):
 3        print("Before the function is called.")
 4        result = func(*args, **kwargs)
 5        print("After the function is called.")
 6        return result
 7    return wrapper
 8
 9@my_decorator
10def greet(name):
11    """Prints hello to someone"""
12    print("Hello {}!".format(name))
13
14greet("Alice")
Before the function is called.
Hello Alice!
After the function is called.

Introspection on Decorated Functions#

As everything (including functions) are objects in Python, we can use instrospection to examine the properties of objecs at runtime. So we can use the “dunder” properties to get a function’s name and documentation string(docstring).

However, when we wrap/decorate a function, by default, we return back the wrapping function’s name and docstring:

1print("Name:", greet.__name__, "\nDoc String:",greet.__doc__)
Name: wrapper 
Doc String: None

To fix this issue, we need to use the @functools.wraps decorator to preserve the orginal function’s attributes.

 1import functools
 2
 3def my_decorator(func):
 4    @functools.wraps(func)
 5    def wrapper(*args, **kwargs):
 6        print("Before the function is called.")
 7        result = func(*args, **kwargs)
 8        print("After the function is called.")
 9        return result
10    return wrapper
11
12@my_decorator
13def greet(name):
14    """Prints hello to someone"""
15    print("Hello {}!".format(name))
16
17greet("Bob")
18print("\nName:", greet.__name__, "\nDoc String:",greet.__doc__)
Before the function is called.
Hello Bob!
After the function is called.

Name: greet 
Doc String: Prints hello to someone

Example Usages#

Logging#

We can use decorators to log function calls:

 1import functools
 2
 3def log_function_call(func):
 4    @functools.wraps(func)
 5    def wrapper(*args, **kwargs):
 6        print("Function:", func.__name__)    # remember, functions are objects
 7        print("Positional arguments:", args)
 8        print("Keyword arguments:", kwargs)
 9        result = func(*args, **kwargs)
10        print("Result:",result);
11        return result
12    return wrapper
13
14@log_function_call
15def add(x, y):
16    """Adds two numbers"""
17    return x + y
18
19print(add(1800, 42))
20print(add.__doc__)    # Another demostration of using @functools.wrap
Function: add
Positional arguments: (1800, 42)
Keyword arguments: {}
Result: 1842
1842
Adds two numbers

Performance Testing#

Decorators can also be used to measure the execution time of a function:

 1import functools
 2import time
 3
 4def timing_decorator(func):
 5    @functools.wraps(func)
 6    def wrapper(*args, **kwargs):
 7        start_time = time.time()
 8        result = func(*args, **kwargs)
 9        end_time = time.time()
10        print("Executing {} took {} seconds".format(func.__name__, end_time - start_time))
11        return result
12    return wrapper
13
14@timing_decorator
15def slow_function():
16    time.sleep(2)
17
18slow_function()
Executing slow_function took 2.005077362060547 seconds

Decorators with Arguments#

When you want a decorator to accept arguments, you need to add another function layer:

  • Outer function: This function takes the decorator arguments.

  • Middle function: This function takes the function to be decorated.

  • Inner function (wrapper): This function wraps original function that adds the extra functionality.

So the general structure appears as -

def decorator_with_arguments(arg1, arg2, ...):
    def middle_function(func):
        @functools.wraps(func)            # Used to maintain decoratored function's "meta" information (e.g., docstring)
        def wrapper(*args, **kwargs):
            # Code to execute before calling the original function
            print("Decorator arguments: {}, {}, ...".format(arg1, arg2, ...))
            result = func(*args, **kwargs)
            # Code to execute after calling the original function
            return result
        return wrapper
    return middle_function

Building upon the performance testing use case:

One of the difficulties with measuring code performance is that a function executes too fast to measure a function’s execution time. To work around this limitation, execute the function many times to gain an accurate sample. In our exaample, we can modify the timing decorator to take an optional parameter for the number of executions.

 1import functools,  time
 2
 3def timing_decorator(execution_count = 1):
 4    def middle_function(func):
 5        @functools.wraps(func)
 6        def wrapper(*args, **kwargs):
 7            start_time = time.time()
 8            for _ in range(execution_count):
 9                result = func(*args, **kwargs)
10            end_time = time.time()
11            print("Executing {} {} times() took {} seconds".format(func.__name__, execution_count, end_time - start_time))
12            return result
13        return wrapper
14    return middle_function
1@timing_decorator(3)
2def slow_function(seconds):
3    """ function sleeps for a certain number of seconds"""
4    time.sleep(seconds)
5
6slow_function(1)
Executing slow_function 3 times() took 3.012141704559326 seconds

Closures and Decorators#

A closure is a function object that retains bindings to the variables that were in its lexical scope when the function was created, even after those variables would normally go out of scope. In other words, a closure allows a function to access variables from an enclosing scope, even after that scope has finished executing.

Closures are created by defining a function inside another function and then returning the inner function. The inner function has access to the variables of the outer function.

Here’s a simple example to illustrate closures:

1def outer_function(message):
2    def inner_function():
3        print(message)
4    return inner_function
5
6closure = outer_function("Hello, World!")
7closure()  # This will print "Hello, World!"
Hello, World!
  • outer_function takes a parameter message and defines an inner function inner_function that prints message.

  • outer_function returns inner_function, which is a closure because it “remembers” the value of message even after outer_function has finished executing.

How Closures Are Used to Implement Decorators#

Decorators rely on closures to modify the behavior of functions or methods. Here’s a step-by-step explanation:

  1. Outer Function: The outer function is called when the decorator is applied. It can take arguments if needed.

  2. Inner Function (Wrapper): The inner function, or wrapper, is defined inside the outer function and has access to the outer function’s variables.

  3. Returning the Wrapper: The outer function returns the inner function, which becomes the decorated function.

Example of a Basic Decorator Using Closures#

Let’s create a simple decorator that adds logging around a function call:

 1def simple_logger(func):
 2    def wrapper(*args, **kwargs):
 3        print("Calling function {} with arguments {} and keyword arguments {}".format(func.__name__,args,kwargs))
 4        result = func(*args, **kwargs)
 5        print("Function {} returned {}".format(func.__name__,result))
 6        return result
 7    return wrapper
 8
 9@simple_logger
10def add(a, b):
11    return a + b
12
13add(2, 3)
Calling function add with arguments (2, 3) and keyword arguments {}
Function add returned 5
5

In this example:

  • simple_logger is the outer function that takes the original function func as an argument.

  • wrapper is the inner function (closure) that logs the function call and its result.

  • simple_logger returns wrapper, so when add is called, the wrapper function is actually executed.

Example of a Decorator with Arguments Using Closures#

Let’s create a decorator that logs messages with a custom prefix:

 1def logger(prefix):
 2    def decorator(func):
 3        def wrapper(*args, **kwargs):
 4            print("{} Calling function {}".format(prefix,func.__name__))
 5            result = func(*args, **kwargs)
 6            print("{} Function {} returned {}".format(prefix,func.__name__,result))
 7            return result
 8        return wrapper
 9    return decorator
10
11@logger("DEBUG:")
12def multiply(a, b):
13    return a * b
14
15multiply(3, 4)
DEBUG: Calling function multiply
DEBUG: Function multiply returned 12
12
  • logger is the outer function that takes a prefix string as an argument and returns the actual decorator decorator.

  • decorator is a function that takes the original function func and returns the wrapper function.

  • wrapper is the closure that has access to both the original function func and the prefix from logger.

  • logger("DEBUG:") returns decorator, which is then applied to multiply.

Why Closures Are Useful for Decorators#

Closures allow the decorator to retain state (such as arguments) and modify the behavior of functions or methods in a flexible way. By leveraging closures, decorators can:

  • Access and manipulate variables from the enclosing scope.

  • Maintain state across multiple calls.

  • Provide a clean and readable way to extend or alter the behavior of functions and methods.

Decorators and Flask#

While we’ll cover Flask in more detail in Section 5, we do want to highlight a couple of uses of decorators within Flask and web-development in general.

Register Route Handlers#

One of the tasks that needs to occur within web applications is to map URLs to the functions that process a specific function. To do that, we use a the “@route” decorator that takes a URL pattern as an argument. The HTTP Method may also be specified in the route as well.

from flask import render_template

@app.route('/hello/<name>')
def hello(name=None):
    return render_template('hello.html', name=name)

Require User Authentication#

def login_required(view):
    @functools.wraps(view)
    def wrapped_view(**kwargs):
        if g.user is None:
            return redirect(url_for('auth.login'))

        return view(**kwargs)

    return wrapped_view

# source: https://flask.palletsprojects.com/en/3.0.x/tutorial/views/

By applying the @login_required decorator, we check if a user ID has been established in the current request (another function performs this if the user ID is in the current session). If the user object is not set, the application redirects the user to the login page. Otherwise, the normal route function(view) is called and returned.

Aspect-Oriented Programming and Decorators#

Aspect-Oriented Programming (AOP) and Python decorators share a common principle: both techniques aim to separate cross-cutting concerns from the main business logic of a program. While AOP is more commonly associated with languages like Java, Python decorators offer a way to implement aspects of AOP in Python.

AOP aims to increase modularity by allowing the separation of cross-cutting concerns (like logging, security, or error handling) by adding additional behavior to existing code (“an advice”) without modifying the code itself.

A decorator is a syntactic feature that adds functionality to an existing function or method (known as advice in AOP terminology). They can be used to implement AOP-like features in Python, handling cross-cutting concerns by “decorating” functions or methods.

How They Work Together#

Implementing Advice: In AOP, advice is additional code you want to run at certain points in your program. In Python, decorators can wrap a function or method to execute code before or after the wrapped function, similar to “before” and “after” advice in AOP.

Pointcut and Join Points: In AOP, a pointcut defines where an advice should be applied, and a join point is a specific point, like method execution. While Python doesn’t have a native concept of pointcuts, decorators can be selectively applied to functions or methods, acting like a manual pointcut.

Separation of Concerns: Both AOP and decorators help to separate concerns, such as logging, error handling, or performance measurement from the main business logic. This makes the code cleaner and more maintainable.

Dynamic Behavior: Just like AOP, decorators can dynamically add behavior to functions or methods without altering their actual code, which is especially useful in scenarios like authorization, where certain actions need to be performed based on dynamic context.

Example: click#

While not strictly an AOP framework, the click library, which is used to create command line interfaces, demonstrates these principles. Primarily, click helps separate the concern of command-line interface handling from the business logic of the application. This separation allows you to focus on the core functionality of your commands without worrying about how to parse arguments or handle user inputs. This additional behavior is dynamic in that the original function does not need to be modified. The advice in this situation is the handling of command-line arguments in a reusable library.

Summary#

Decorators are a very powerful and useful tool in Python. They allow for cleaner and more readable code, especially when adding common functionality to multiple functions or methods. Understanding and using decorators can greatly enhance your Python programming skills.

Remember, while decorators are powerful, they should be used judiciously, as they can sometimes make your code more complex and harder to understand.