Functional tools

This section presents the most important tools from functools

decorator

You already encountered a decorator in the memory profiling section. Let’s create our own decorator to understand the process.

A decorator takes a callable as input argument, and returns an altered version of the callable which is then substituted: the call to the original callable will trigger the call to the altered version instead.

Exercise (easy)

Modify the code below in order to print the args and kwargs before the call and the results after the call. Implement and test.

 1from collections.abc import Callable
 2
 3def decorator_name(func_to_decorate: Callable) -> Callable:
 4   def altered_function(*args, **kwargs):
 5      ... # do something before the call
 6      results = func_to_decorate(*args, **kwargs) # perform the expected task
 7      ... # do something after the call
 8      return results
 9   return altered_function
10
11@decorator_name # equivalent to : add = decorator_name(add)
12def add(x: float, y: float) -> float:
13   return x + y
Correction
tests
1def test_print_input_output(capsys):
2    @print_input_output
3    def return_true(first, second):
4        return True
5
6    return_true(0, second=1)
7    captured = capsys.readouterr()
8    assert captured.out == "(0,) {'second': 1}\nTrue\n"

wraps

If one defines a totally useless decorator such the one below, and prints some information about the decorated function: its name, its docstring and its type annotations. The result is disappointing, especially when the decorator is used on several functions:

 1# import functools
 2
 3def change_nothing(func_to_decorate: Callable) -> Callable:
 4   # @functools.wraps(func_to_decorate)
 5   def altered_function(*args, **kwargs):
 6      func_to_decorate(*args, **kwargs)
 7      return func_to_decorate(*args, **kwargs)
 8
 9   return altered_function
10
11
12@change_nothing
13def return_true() -> bool:
14   """return_true docstring"""
15   return True
16
17
18def show_wraps_utility() -> None:
19   print(return_true.__name__)
20   print(return_true.__doc__)
21   print(return_true.__annotations__)

All the decorated function information are replaced by the wrapper (altered_function) information. To keep the original information the functools.wraps is very useful.

$ altered_function
$ None
$ {}

Exercise (easy)

Uncomment the two commented lines and compare to the original version.

Correction

with wraps the decorated function keeps its original information.

$ return_true
$ return_true docstring
$ {'return': <class 'bool'>}

cache

In the algorithm course, the notion of caching will be heavily used. Caching is storing in memory the result of previous (costly) computations in order to efficiently being able to retrieve them if identical input arguments are used again.

Exercise (easy)

What result wil the following code produce?

 1import functools
 2from time import sleep
 3
 4@functools.cache
 5def expensive_task(number: int) -> int:
 6   sleep(0.5)
 7   print(f"calculating, number={number}")
 8   return number
 9
10
11def show_cache_utility() -> None:
12   print(expensive_task(0))
13   print(expensive_task(0))
Correction
$ calculating, number=0
$ 0
$ 0

The ‘expensive task’ is done only once. The second time the result is fetched in cache.

Exercise (intermediate)

  1. Based on a dict, create your own cache decorator for positional arguments only. Test it.

  2. Why keyword arguments would pose a challenge?

Correction
  1. src
     1def my_cache[Out](func: Callable[..., Out]):  # ignore the type hints
     2    _cache: dict[tuple[Hashable, ...], Out] = {}
     3
     4    @functools.wraps(func)
     5    def wrapper(*args: Hashable) -> Out:
     6        cached: Out | None = _cache.get(args)
     7        if cached is not None:
     8            return cached
     9        value: Out = func(*args)
    10        _cache[args] = value
    11        return value
    12
    13    return wrapper
    
  2. Because a dict is not hashable and a pre-processing of positional and keyword arguments has to be made to use them as dict keys. The functools.wraps works with both though.

partial

Imagine the following setup:

1def compute_something(unknown: float, param: float) -> float: ...
2
3
4def find_root(func: Callable[[float], float]): ...

The function find_root works on a function of the unknown only. However, compute_something requires also param. There is a way to embed the value of param in a function definition (either manually or with functools.partial)

 1def show_partial_utility() -> None:
 2   param = 0.0
 3
 4   def partial_of_compute_something_manually(unknown: float) -> float:
 5      return compute_something(unknown, param)
 6
 7   _ = find_root(partial_of_compute_something_manually)
 8
 9   partial_of_compute_something = functools.partial(compute_something, param=0)
10   _ = find_root(partial_of_compute_something)