Python

Write an awesome doc for Python. A very nice an practical one extracted from Python official documentation.

View on GitHub

Decorators in Python

First Class Citizens

Functions in Python are first class citizens, i.e. they support operations such as being passed as an argument, returned from a function, modified, and assigned to a variable.

from typing import Callable
# Reassign this function to a new variable
def plus_one(number: int) -> int:
    return number + 1
add_one = plus_one
add_one(1)

# Define function inside another function
def func() -> None:
    def func2() -> None:
        pass

# Passing a function as an argument to another function
def perform_task(task_name: str, callback: Callable[[str], None]):
    print(f"Starting task: {task_name}...")
    print(f"Task ({task_name}) completed!")
    callback(task_name)
def notify_completion(task_name: str):
    print(f"Callback: {task_name} is complete!")
perform_task("Data Processing", notify_completion)

# Return a function
def outer_func():
    def return_me():
        pass
    return return_me

Closures & Decorators

from typing import Callable
def greet_generator(name: str) -> Callable[[], None]:
    def greet():
        print(f"Hi dear {name}!")
    return greet
greet = greet_generator("Closure")
greet()

Function-based Decorators

Here you can see that we are using all the previously mentioned features in one place:

import datetime
from typing import Callable, Any
def logger(func: Callable[..., Any]) -> Callable[[], None]:
    """ Log when function execution started and when it ended. """
    def wrapper(*args: Any, **kwargs: Any):
        print(f"Before the function call: {datetime.datetime.now()}")
        print(args)
        print(kwargs)
        func()
        print(f"After the function call: {datetime.datetime.now()}")
    return wrapper
@logger
def greet():
    print("Hello!")
greet()

[!NOTE]

This is how you can define a general purpose decorator which will accept any number of arguments.

from typing import Callable
def split(callback: Callable[..., str]) -> Callable[[], list[str]]:
    def wrapper():
        result = callback()
        return result.split()
    return wrapper
def uppercase(callback: Callable[..., str]) -> Callable[[], str]:
    def wrapper():
        result = callback()
        return result.upper()
    return wrapper

@split
@uppercase
def say_hi() -> str:
    return 'hi there'

print(say_hi())

[!CAUTION]

Order of decorators matter: in python decorators are executed bottom-up. Meaning if we revert the order of decorators here we’ll get an error:

@uppercase
@split
def say_hi() -> str:

Accessing Arguments Passed to The Function

From time to time we need to perform some sort of operation on the arguments of a function through decorators.

from typing import Callable


def is_gmail(function: Callable[[str], None]) -> Callable[[str], None]:
    def wrapper(value: str):
        if value.endswith("@gmail.com"):
            function(value)
        else:
            raise Exception("It was not a Google mail address")
    return wrapper


@is_gmail
def send_notification(email: str) -> None:
    print("Sending notification to {}".format(email))


send_notification("")

A Critical Issue With Decorators 🪲

Error on line 42, something like this will happen to you if you do not use functools.wraps decorator

YouTube/Aparat

Class-based Decorators

from typing import Callable
class Uppercase:
    def __init__(self, function: Callable[..., str]) -> None:
        self.function = function
    def __call__(self, *args, **kwargs) -> str:
        result = self.function(*args, **kwargs)
        return result.upper()
@Uppercase
def greet() -> str:
    return "Huawei - Betelgeuse"
print(greet())

Why Class-based Decorators?

  1. Stateful: use instance variables for greater flexibility.
    from typing import Callable, Any
    class Counter:
        def __init__(self, function: Callable[..., Any]) -> None:
            self.function = function
            self.count = 0
        def __call__(self, *args, **kwargs) -> Callable[..., Any]:
            self.count += 1
            return self.function(*args, **kwargs)
    @Counter
    def f():
        pass
    f()
    f()
    f()
    
  2. Readability: in a complex decorators, encapsulating logic in a class can make the code more organized and easier to understand.

Another Perfect Real World Example

from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50))  # Subsequent calls with the same argument are much faster
print(fibonacci(100))

Ref