How To Use Python Decorator To Implement Logging, Authentication, and Caching Examples

Python offers developers various tools and techniques to enhance their coding experience. Among these, decorators stand out as a powerful feature for dynamically altering the functionality of functions, methods, or classes. While decorators might seem complex at first, understanding their application scenarios can significantly improve your Python code’s elegance and efficiency.

1. Understanding Python Decorators.

  1. In Python, decorators are functions that modify the behavior of other functions or methods.
  2. They allow you to add functionality to existing code without modifying the code itself.
  3. By using decorators, you can easily implement cross-cutting concerns such as logging, timing, authentication, or validation, making your code more modular and maintainable.

1.1 Understanding The Wrapper Function In the Python Decorator Function.

  1. In Python, a wrapper function refers to an inner function that is defined within another function.
  2. In the context of decorators, the wrapper function is the function that is returned by the decorator function, and it wraps the original function.
  3. It acts as a mediator that allows you to add extra functionality to the original function without modifying its code directly.
  4. The primary purpose of the wrapper function is to execute some code before and/or after the original function is called.
  5. This provides a way to customize the behavior of the original function without changing its core implementation.
  6. The wrapper function typically includes the call to the original function and any additional code that needs to be executed before or after the original function call.
  7. Here’s an example to illustrate the concept:
    def my_decorator(func):
        def wrapper():
            print("Something is happening before the function is called.")
            func()
            print("Something is happening after the function is called.")
        return wrapper
    
    @my_decorator
    def say_hello():
        print("Hello!")
    
    say_hello()
    
  8. In this example, the `my_decorator` function is the decorator, and the `wrapper` is the inner function that adds additional functionality around the `say_hello` function.
  9. When `say_hello` is called, the `wrapper` function executes some code before and after the `say_hello` function call.
  10. The `wrapper` function serves as a bridge that allows you to modify or enhance the behavior of the `say_hello` function without directly altering its implementation.
  11. Below is the execution output of the above example code.
    Something is happening before the function is called.
    Hello!
    Something is happening after the function is called.

2. Application Scenarios.

2.1 Logging.

  1. Logging is an essential aspect of any robust software application.
  2. With decorators, you can create a logging decorator to track the execution of specific functions, providing valuable insights into their behavior.
    def log_function_data(func):
        def wrapper(*args, **kwargs):
            print(f"Function {func.__name__} called with arguments {args} and {kwargs}")
            return func(*args, **kwargs)
        return wrapper
    
    @log_function_data
    def calculate_square(n):
        return n * n
    
    def test_calculate_square():
        for i in range(10):
            print(calculate_square(i))
    
    if __name__ == "__main__":
        test_calculate_square()
  3. Output.
    0
    Function calculate_square called with arguments (1,) and {}
    1
    Function calculate_square called with arguments (2,) and {}
    4
    Function calculate_square called with arguments (3,) and {}
    9
    Function calculate_square called with arguments (4,) and {}
    16
    Function calculate_square called with arguments (5,) and {}
    25
    Function calculate_square called with arguments (6,) and {}
    36
    Function calculate_square called with arguments (7,) and {}
    49
    Function calculate_square called with arguments (8,) and {}
    64
    Function calculate_square called with arguments (9,) and {}
    81

2.2 Authentication.

  1. Implementing authentication checks for different functions can be tedious.
  2. Using decorators, you can create an authentication decorator to ensure that only authorized users can access sensitive parts of your code.
    def check_user_authenticated():
        user_name = input("Please input your user name:")
        passwd = input("Please input your password:")
    
        if user_name == 'hello' and passwd == 'hi':
            print('Authentication is success.')
            return True
        else:
            print('Authentication is failed.')
            return False
    
    def authenticate(func):
        def wrapper(*args, **kwargs):
            if check_user_authenticated():
                return func(*args, **kwargs)
            else:
                raise PermissionError("Not authenticated")
        return wrapper
    
    @authenticate
    def sensitive_operation():
        # sensitive operations go here
        print('sensitive_operation() is invoked.')
    
    if __name__ == "__main__":
        sensitive_operation()
  3. When you run the above code, if you input the correct user_name and password, you will get the below output.
    Please input your user name:hello
    Please input your password:hi
    Authentication is success.
    sensitive_operation() is invoked.
  4. If you input the wrong user_name and password, you will get the below output.
    Please input your user name:jerry
    Please input your password:hi
    Authentication is failed.
    Traceback (most recent call last):
      File "d:\WorkSpace\Work\python-courses\python-special-attributes-methods\log_auth_cache_by_decorator.py", line 42, in <module>
        sensitive_operation()
      File "d:\WorkSpace\Work\python-courses\python-special-attributes-methods\log_auth_cache_by_decorator.py", line 32, in wrapper
        raise PermissionError("Not authenticated")
    PermissionError: Not authenticated

2.3 Caching.

  1. Decorators can be used for caching expensive function calls, helping to improve the performance of your application, especially when dealing with time-consuming operations or I/O.c
    def cache(func):
        cached_values = {}
        def wrapper(*args):
            print('args:',args)
            if args in cached_values:
                print('return cached_values[', args, ']:', cached_values[args])
                return cached_values[args]
            result = func(*args)
            cached_values[args] = result
            print('set cached_values[', args, ']:', result)
            return result
        return wrapper
    
    @cache
    def fibonacci(n):
        if n < 2:
            return n
        return fibonacci(n - 1) + fibonacci(n - 2)
    
    if __name__ == "__main__":
        print(fibonacci(3))
  2. Output.
    args: (3,)
    args: (2,)
    args: (1,)
    set cached_values[ (1,) ]: 1
    args: (0,)
    set cached_values[ (0,) ]: 0
    set cached_values[ (2,) ]: 1
    args: (1,)
    return cached_values[ (1,) ]: 1
    set cached_values[ (3,) ]: 2
    2
  3. The above source code demonstrates a decorator named `cache` that can be used to cache the results of a function. It then applies the `cache` decorator to the `fibonacci` function, effectively caching the results of the Fibonacci sequence computation.
  4. The `cache` function is defined, which takes another function `func` as an argument. Inside the `cache` function, there is a dictionary called `cached_values` that serves as a cache to store the results of function calls ( for example, it stores the result of the previous function call fibonacci(2) , and reads it from the cache when it calls the function fibonacci(2) again later).
  5. Inside the `cache` function, there is an inner function named `wrapper` that takes in `*args` as its parameters. This allows the `wrapper` function to accept any number of arguments.
  6. Within the `wrapper` function, it first checks if the arguments `args` are already in the `cached_values` dictionary. If they are, the function returns the cached value directly.
  7. If the arguments `args` are not in the `cached_values` dictionary, it calls the original function `func` with the arguments `args` and stores the result in the `cached_values` dictionary before returning the result.
  8. Finally, the `wrapper` function is returned, effectively replacing the original function. This new function retains the functionality of the original function but with added caching capabilities.
  9. The `@cache` decorator is then applied to the `fibonacci` function. This means that the `fibonacci` function will have its results cached using the caching mechanism provided by the `cache` decorator.
  10. The `fibonacci` function itself is a recursive function that computes the Fibonacci sequence. The `cache` decorator helps optimize the function by caching previously computed results, thereby reducing redundant computations and improving the overall performance of the function for subsequent calls with the same input values.
  11. Here’s another example of a caching decorator for a function that calculates the square of a number:
    def memoize(func):
        cache = {}
    
        def wrapper(n):
            if n not in cache:
                cache[n] = func(n)
            return cache[n]
    
        return wrapper
    
    @memoize
    def calculate_square(n):
        print(f"Calculating square of {n}...")
        return n * n
    
    def test_calculate_square():
        # Testing the caching decorator
        print(calculate_square(4))  # Will calculate and cache the result
        print(calculate_square(5))  # Will calculate and cache the result
        print(calculate_square(4))  # Will retrieve the result from the cache
    
    
    if __name__ == "__main__":
        test_calculate_square()
  12. Output.
    Calculating square of 4...
    16
    Calculating square of 5...
    25
    16

3. Conclusion.

  1. Python decorators are a powerful tool for enhancing the functionality and maintainability of your code.
  2. By leveraging decorators, you can easily incorporate cross-cutting concerns, such as logging, authentication, and caching, without cluttering your main codebase.
  3. Understanding and utilizing decorators effectively can elevate your Python programming skills and help you build more robust and efficient applications.

Leave a Comment

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.