4.4. Decorate Function

  • Decorator must return reference to wrapper

  • wrapper is a closure function

  • wrapper name is a convention, but you can name it anyhow

  • wrapper gets arguments passed to function

Definition:

>>> def mydecorator(func):
...     def wrapper(*args, **kwargs):
...         return func(*args, **kwargs)
...     return wrapper

Decoration:

>>> @mydecorator
... def myfunction():
...     ...

Usage:

>>> myfunction()

4.4.1. Example

>>> def run(func):
...     def wrapper(*args, **kwargs):
...         return func(*args, **kwargs)
...     return wrapper
>>>
>>>
>>> @run
... def hello(name):
...     return f'My name... {name}'
>>>
>>>
>>> hello('José Jiménez')
'My name... José Jiménez'

4.4.2. Use Case - 0x01

  • Check if file exists, before executing function:

>>> import os
>>>
>>>
>>> def ifexists(func):
...     def wrapper(file):
...         if os.path.exists(file):
...             return func(file)
...         else:
...             print(f'File {file} does not exist')
...     return wrapper
>>>
>>>
>>> @ifexists
... def display(file):
...     print(f'Printing file {file}')
>>>
>>>
>>> display('/etc/passwd')
Printing file /etc/passwd
>>>
>>> display('/tmp/passwd')
File /tmp/passwd does not exist

4.4.3. Use Case - 0x02

  • Timeit

>>> from time import time
>>>
>>>
>>> def timeit(func):
...     def wrapper(*args, **kwargs):
...         start = time()
...         result = func(*args, **kwargs)
...         end = time()
...         duration = end - start
...         print(f'Duration: {duration}')
...         return result
...     return wrapper
>>>
>>>
>>> @timeit
... def add(a, b):
...     return a + b
>>>
>>>
>>> add(1, 2)  
Duration: 0:00:00.000006
3
>>>
>>> add(1, b=2)  
Duration: 0:00:00.000007
3
>>>
>>> add(a=1, b=2)  
Duration: 0:00:00.000008
3

4.4.4. Use Case - 0x03

  • Debug

>>> def debug(func):
...     def wrapper(*args, **kwargs):
...         function = func.__name__
...         print(f'Calling: {function=}, {args=}, {kwargs=}')
...         result = func(*args, **kwargs)
...         print(f'Result: {result}')
...         return result
...     return wrapper
>>>
>>>
>>> @debug
... def add(a, b):
...     return a + b
>>>
>>>
>>> add(1, 2)  
Calling: function='add', args=(1, 2), kwargs={}
Result: 3
3
>>>
>>> add(1, b=2)  
Calling: function='add', args=(1,), kwargs={'b': 2}
Result: 3
3
>>>
>>> add(a=1, b=2)  
Calling: function='add', args=(), kwargs={'a': 1, 'b': 2}
Result: 3
3

4.4.5. Use Case - 0x04

  • Deprecated

>>> import warnings
>>>
>>>
>>> def deprecated(func):
...     def wrapper(*args, **kwargs):
...         name = func.__name__
...         file = func.__code__.co_filename
...         line = func.__code__.co_firstlineno + 1
...         message = f'Call to deprecated function `{name}` in {file} at line {line}'
...         warnings.warn(message, DeprecationWarning)
...         return func(*args, **kwargs)
...     return wrapper
>>>
>>>
>>> @deprecated
... def add(a, b):
...     return a + b
>>>
>>>
>>> myfunction()  
/home/python/myscript.py:11: DeprecationWarning: Call to deprecated function `add` in /home/python/myscript.py at line 19

4.4.6. Use Case - 0x05

  • Stacked Decorators

>>> from datetime import datetime
>>> import logging
>>>
>>> logging.basicConfig(
...     level='DEBUG',
...     format='{asctime}, "{levelname}", "{message}"',
...     datefmt='"%Y-%m-%d", "%H:%M:%S"',
...     style='{')
>>>
>>> log = logging.getLogger(__name__)
>>>
>>>
>>> def timeit(func):
...     def wrapper(*args, **kwargs):
...         start = datetime.now()
...         result = func(*args, **kwargs)
...         end = datetime.now()
...         log.info(f'Duration: {end - start}')
...         return result
...     return wrapper
>>>
>>>
>>> def debug(func):
...     def wrapper(*args, **kwargs):
...         function = func.__name__
...         log.debug(f'Calling: {function=}, {args=}, {kwargs=}')
...         result = func(*args, **kwargs)
...         log.debug(f'Result: {result}')
...         return result
...     return wrapper
>>>
>>>
>>> @timeit
... @debug
... def add(a, b):
...     return a + b
>>>
>>>
>>> add(1, 2)  
"1969-07-21", "02:56:15", "DEBUG", "Calling: function='add', args=(1, 2), kwargs={}"
"1969-07-21", "02:56:15", "DEBUG", "Result: 3"
"1969-07-21", "02:56:15", "INFO", "Duration: 0:00:00.000209"
>>>
>>> add(1, b=2)  
"1969-07-21", "02:56:15", "DEBUG", "Calling: function='add', args=(1,), kwargs={'b': 2}"
"1969-07-21", "02:56:15", "DEBUG", "Result: 3"
"1969-07-21", "02:56:15", "INFO", "Duration: 0:00:00.000154"
>>>
>>> add(a=1, b=2)  
"1969-07-21", "02:56:15", "DEBUG", "Calling: function='add', args=(), kwargs={'a': 1, 'b': 2}"
"1969-07-21", "02:56:15", "DEBUG", "Result: 3"
"1969-07-21", "02:56:15", "INFO", "Duration: 0:00:00.000083"

4.4.7. Use Case - 0x06

  • Global Scope Cache

Recap information about factorial (n!):

5! = 5 * 4!
4! = 4 * 3!
3! = 3 * 2!
2! = 2 * 1!
1! = 1 * 0!
0! = 1
n! = n * (n-1)!  # 1 for n=0
>>> def factorial(n):
...     if n == 0:
...         return 1
...     else:
...         return n * factorial(n-1)

4.4.8. Use Case - 0x07

Cache with global scope:

>>> _cache = {}
>>>
>>> def cache(func):
...     def wrapper(n):
...         if n not in _cache:
...             _cache[n] = func(n)
...         return _cache[n]
...     return wrapper
>>>
>>>
>>> @cache
... def factorial(n):
...     if n == 0:
...         return 1
...     else:
...         return n * factorial(n-1)
>>>
>>>
>>> factorial(5)
120
>>>
>>> print(_cache)
{0: 1, 1: 1, 2: 2, 3: 6, 4: 24, 5: 120}

4.4.9. Use Case - 0x08

  • Local Scope Cache

Cache with local scope:

>>> def cache(func):
...     _cache = {}
...     def wrapper(n):
...         if n not in _cache:
...             _cache[n] = func(n)
...         return _cache[n]
...     return wrapper
>>>
>>>
>>> @cache
... def factorial(n):
...     if n == 0:
...         return 1
...     else:
...         return n * factorial(n-1)
>>>
>>>
>>> factorial(5)
120

4.4.10. Use Case - 0x09

Cache with embedded scope:

>>> def cache(func):
...     def wrapper(n):
...         if n not in wrapper._cache:
...             wrapper._cache[n] = func(n)
...         return wrapper._cache[n]
...     if not hasattr(wrapper, '_cache'):
...         setattr(wrapper, '_cache', {})
...     return wrapper
>>>
>>>
>>> @cache
... def factorial(n: int) -> int:
...     if n == 0:
...         return 1
...     else:
...         return n * factorial(n-1)
>>>
>>>
>>> print(factorial(4))
24
>>>
>>> print(factorial._cache)
{0: 1, 1: 1, 2: 2, 3: 6, 4: 24}
>>>
>>> print(factorial(6))
720
>>>
>>> print(factorial._cache)
{0: 1, 1: 1, 2: 2, 3: 6, 4: 24, 5: 120, 6: 720}
>>>
>>> print(factorial(6))
720
>>>
>>> print(factorial(3))
6
>>>
>>> print(factorial._cache)
{0: 1, 1: 1, 2: 2, 3: 6, 4: 24, 5: 120, 6: 720}

4.4.11. Use Case - 0x0A

  • Database Cache

>>> DATABASE = {
...     'mlewis':       {'name': 'Melissa Lewis',   'email': 'melissa.lewis@nasa.gov'},
...     'mwatney':      {'name': 'Mark Watney',     'email': 'mark.watney@nasa.gov'},
...     'avogel':       {'name': 'Alex Vogel',      'email': 'alex.vogel@nasa.gov'},
...     'rmartinez':    {'name': 'Rick Martinez',   'email': 'rick.martinez@nasa.gov'},
...     'bjohanssen':   {'name': 'Beth Johanssen',  'email': 'beth.johanssen@nasa.gov'},
...     'cbeck':        {'name': 'Chris Beck',      'email': 'chris.beck@nasa.gov'},
... }
>>>
>>> _cache = {}
>>>
>>> def cache(func):
...     def wrapper(username):
...         if username not in _cache:
...             _cache[username] = func(username)
...         return _cache[username]
...     return wrapper
>>>
>>>
>>> @cache
... def db_search(username):
...     return DATABASE[username]['name']
>>>
>>>
>>>
>>> db_search('mwatney')  # not in cache, searches database and updates cache with result
'Mark Watney'
>>>
>>> db_search('mwatney')  # found in cache and returns from it, no database search
'Mark Watney'
>>>
>>> print(_cache)
{'mwatney': 'Mark Watney'}

4.4.12. Use Case - 0x0B

  • FastAPI URL Routing

>>> 
... from fastapi import FastAPI
...
... app = FastAPI()
...
...
... @app.get('/')
... async def index():
...     return {'message': 'Hello World'}
...
...
... @app.get('/user/{pk}')
... async def user(pk: int):
...     return {'pk': pk}
...
...
... @app.get('/search')
... async def items(q: str | None = None):
...     return {'q': q}

4.4.13. Use Case - 0x0C

  • Django Login Required

Decorator checks whether user is_authenticated. If not, user will be redirected to login page:

>>> 
... from django.shortcuts import render
...
...
... def edit_profile(request):
...     if not request.user.is_authenticated:
...         return render(request, 'templates/login_error.html')
...     else:
...         return render(request, 'templates/edit-profile.html')
...
...
... def delete_profile(request):
...     if not request.user.is_authenticated:
...         return render(request, 'templates/login_error.html')
...     else:
...         return render(request, 'templates/delete-profile.html')
>>> 
... from django.shortcuts import render
... from django.contrib.auth.decorators import login_required
...
...
... @login_required
... def edit_profile(request):
...     return render(request, 'templates/edit-profile.html')
...
...
... @login_required
... def delete_profile(request):
...     return render(request, 'templates/delete-profile.html')

4.4.14. Assignments

Code 4.46. Solution
"""
* Assignment: Decorator Function Check
* Complexity: easy
* Lines of code: 3 lines
* Time: 5 min

English:
    1. Create decorator `check`
    2. Decorator calls function, only when `echo.disabled` is `False`
    3. Note that decorators overwrite reference and in `wrapper`
       you must check if `wrapper.disabled` is `False`
    4. Else raise an exception `PermissionError`
    5. Run doctests - all must succeed

Polish:
    1. Stwórz dekorator `check`
    2. Dekorator wywołuje funkcję, tylko gdy `echo.disabled` jest `False`
    3. Zwróć uwagę, że dekoratory nadpisują referencje i we `wrapper`
       musisz sprawdzić czy `wrapper.disabled` jest `False`
    4. W przeciwnym przypadku podnieś wyjątek `PermissionError`
    5. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isfunction

    >>> assert isfunction(check), \
    'Create check() function'

    >>> assert isfunction(check(lambda: ...)), \
    'check() should take function as an argument'

    >>> @check
    ... def echo(text):
    ...     print(text)

    >>> assert isfunction(echo), \
    'Decorator check() should return a function'

    >>> echo.disabled = False
    >>> echo('hello')
    hello

    >>> echo.disabled = True
    >>> echo('hello')
    Traceback (most recent call last):
    PermissionError: Function is disabled

    >>> assert hasattr(echo, 'disabled')
"""


# type: Callable[[Callable], Callable]
def check(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)

    return wrapper


Code 4.47. Solution
"""
* Assignment: Decorator Function Astronauts
* Complexity: easy
* Lines of code: 3 lines
* Time: 8 min

English:
    1. Modify decorator `check_astronauts`
    2. To answer if person is an astronaut check field:
       a. `is_astronaut` in `crew: list[dict]`
    3. Decorator will call function, only if all crew members are astronauts
    4. If any member is not an astronaut raise `PermissionError` and print
       his first name and last name
    5. Run doctests - all must succeed

Polish:
    1. Zmodufikuj dekorator `check_astronauts`
    2. Aby odpowiedzieć czy osoba jest astronautą sprawdź pole:
       a. `is_astronaut` in `crew: list[dict]`
    3. Dekorator wywoła funkcję, tylko gdy wszyscy załoganci są astronautami
    4. Jeżeli, jakikolwiek członek nie jest astronautą, podnieś wyjątek
       `PermissionError` i wypisz jego imię i nazwisko
    5. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isfunction

    >>> assert isfunction(check_astronauts), \
    'Create check_astronauts() function'

    >>> assert isfunction(check_astronauts(lambda: ...)), \
    'check_astronauts() should take function as an argument'

    >>> @check_astronauts
    ... def launch(crew):
    ...     crew = ', '.join(astro['name'] for astro in crew)
    ...     return f'Launching: {crew}'

    >>> launch(CREW_PRIMARY)
    'Launching: Pan Twardowski, Mark Watney, Melissa Lewis'

    >>> launch(CREW_BACKUP)
    Traceback (most recent call last):
    PermissionError: Alex Vogel is not an astronaut
"""

CREW_PRIMARY = [
    {'is_astronaut': True, 'name': 'Pan Twardowski'},
    {'is_astronaut': True, 'name': 'Mark Watney'},
    {'is_astronaut': True, 'name': 'Melissa Lewis'}]

CREW_BACKUP = [
    {'is_astronaut': True, 'name': 'Melissa Lewis'},
    {'is_astronaut': True, 'name': 'Mark Watney'},
    {'is_astronaut': False, 'name': 'Alex Vogel'}]


# type: Callable[[Callable], Callable]
def check_astronauts(func):
    def wrapper(crew):
        return func(crew)

    return wrapper


Code 4.48. Solution
"""
* Assignment: Decorator Function Memoization
* Complexity: easy
* Lines of code: 3 lines
* Time: 8 min

English:
    1. Create decorator `@cache`
    2. Decorator must check before running function, if for given argument
       the computation was already done:
       a. if yes, return from `_cache`
       b. if not, calculate new result, update cache and return value
    3. Using `timeit` compare execution time (it might take around 30 seconds)
    4. Last three tests (prints) are only infomation about execution time
       to see it, remove comment '# doctest: +SKIP' this renders
       test failures, but in return you'll get information about execution time
    5. Run doctests - all must succeed (beside three prints)

Polish:
    1. Stwórz dekorator `@cache`
    2. Decorator ma sprawdzać przed uruchomieniem funkcji, czy dla danego
       argumentu wynik został już wcześniej obliczony:
       a. jeżeli tak, zwróć dane z `_cache`
       b. jeżeli nie, oblicz, zaktualizuj `_cache` i zwróć wartość
    3. Używając `timeit` porównaj czas wykonywania (może trwać około 30 sekund)
    4. Ostatnie trzy testy (printy) to tylko informacja o czasie wykonywania
       aby ją zobaczyć, usuń komentarz '# doctest: +SKIP' to spowoduje,
       że testy przestaną przechodzić, ale w zamian wyświetlą czas wykonywania
    5. Uruchom doctesty - wszystkie muszą się powieść (poza trzema printami)

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from timeit import timeit
    >>> from inspect import isfunction
    >>> sys.setrecursionlimit(5000)

    >>> assert type(_cache) is dict, \
    'Cache storage should be a dict'

    >>> assert len(_cache) == 0, \
    'Cache storage should be empty'

    >>> assert isfunction(cache), \
    'Create cache() function'

    >>> assert isfunction(cache(lambda: ...)), \
    'cache() should take function as an argument'

    >>> @cache
    ... def fn1(n):
    ...     if n == 0:
    ...         return 1
    ...     else:
    ...         return n * fn1(n - 1)

    >>> def fn2(n):
    ...     if n == 0:
    ...         return 1
    ...     else:
    ...         return n * fn2(n - 1)

    >>> cached = timeit(  # doctest: +SKIP
    ...     stmt='fn1(500); fn1(400); fn1(450); fn1(350)',
    ...     globals=globals(),
    ...     number=10_000)

    >>> uncached = timeit(  # doctest: +SKIP
    ...     stmt='fn2(500); fn2(400); fn2(450); fn2(350)',
    ...     globals=globals(),
    ...     number=10_000)

    >>> ratio = uncached / cached  # doctest: +SKIP
    >>> print(f'With Cache: {cached:.4f} seconds')  # doctest: +SKIP
    >>> print(f'No Cache time: {uncached:.3f} seconds')  # doctest: +SKIP
    >>> print(f'Cached solution is {ratio:.1f} times faster')  # doctest: +SKIP

    TODO: Make tests faster
"""

_cache = {}


# type: Callable[[Callable], Callable]
def cache(func):
    def wrapper(n):
        return func(n)

    return wrapper


Code 4.49. Solution
""""
* Assignment: Decorator Function Abspath
* Complexity: easy
* Lines of code: 5 lines
* Time: 5 min

English:
    1. Absolute path is when `path` starts with `current_directory`
    2. Create function decorator `abspath`
    3. If `path` is relative, then `abspath` will convert it to absolute
    4. If `path` is absolute, then `abspath` will not modify it
    5. Note: if you are using Windows operating system,
       then one doctest (with absolute path) can fail
    6. Run doctests - all must succeed

Polish:
    1. Ścieżka bezwzględna jest gdy `path` zaczyna się od `current_directory`
    2. Stwórz funkcję dekorator `abspath`
    3. Jeżeli `path` jest względne, to `abspath` zamieni ją na bezwzględną
    4. Jeżeli `path` jest bezwzględna, to `abspath` nie będzie jej modyfikował
    5. Uwaga: jeżeli korzystasz z systemu operacyjnego Windows,
       to jeden z doctestów (ścieżki bezwzględnej) może nie przejść pomyślnie
    6. Uruchom doctesty - wszystkie muszą się powieść

Hints:
    * `Path(filename).absolute()`

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isfunction

    >>> assert isfunction(abspath), \
    'Create abspath() function'

    >>> assert isfunction(abspath(lambda: ...)), \
    'abspath() should take function as an argument'

    >>> @abspath
    ... def display(path):
    ...     return str(path)

    >>> current_dir = str(Path().cwd())
    >>> display('iris.csv').startswith(current_dir)
    True
    >>> display('iris.csv').endswith('iris.csv')
    True
    >>> display('/home/python/iris.csv')
    '/home/python/iris.csv'

TODO: Windows Path().absolute()
TODO: Test if function was called
"""

from pathlib import Path


# type: Callable[[Callable], Callable]
def abspath():
    ...

Code 4.50. Solution
"""
* Assignment: Decorator Function Numeric
* Complexity: easy
* Lines of code: 4 lines
* Time: 5 min

English:
    1. Modify decorator `numeric`
    2. Decorator must check arguments `a` and `b` types
    3. If type `a` or `b` are not `int` or `float`
       raise exception `TypeError`
    4. Run doctests - all must succeed

Polish:
    1. Zmodyfikuj dekorator `numeric`
    2. Dekorator ma sprawdzać typy argumentów `a` oraz `b`
    3. Jeżeli typ `a` lub `b` nie jest `int` lub `float`
       to podnieś wyjątek `TypeError`
    4. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isfunction

    >>> assert isfunction(numeric), \
    'Create numeric() function'

    >>> assert isfunction(numeric(lambda: ...)), \
    'numeric() should take function as an argument'

    >>> @numeric
    ... def add(a, b):
    ...     return a + b

    >>> add(1, 1)
    2
    >>> add(1.5, 2.5)
    4.0
    >>> add(-1, 1.5)
    0.5

    >>> add('one', 1)
    Traceback (most recent call last):
    TypeError: Argument "a" must be int or float
    >>> add(1, 'two')
    Traceback (most recent call last):
    TypeError: Argument "b" must be int or float

    >>> add(True, 0)
    Traceback (most recent call last):
    TypeError: Argument "a" must be int or float
    >>> add(0, True)
    Traceback (most recent call last):
    TypeError: Argument "b" must be int or float
"""


# type: Callable[[Callable], Callable]
def numeric(func):
    def wrapper(a, b):
        return func(a, b)

    return wrapper


Code 4.51. Solution
"""
* Assignment: Decorator Function TypeCheck
* Complexity: hard
* Lines of code: 15 lines
* Time: 21 min

English:
    1. Modify decorator `typecheck`
    2. Decorator checks types of all arguments (`*args` oraz `**kwargs`)
    3. Decorator checks return type
    4. When received type is not expected raise `TypeError` with:
       a. argument name
       b. actual type
       c. expected type
    5. Run doctests - all must succeed

Polish:
    1. Zmodyfikuj dekorator `typecheck`
    2. Dekorator sprawdza typy wszystkich argumentów (`*args` oraz `**kwargs`)
    3. Dekorator sprawdza typ zwracany
    4. Gdy otrzymany typ nie jest równy oczekiwanemu podnieś `TypeError` z:
       a. nazwa argumentu
       b. aktualny typ
       c. oczekiwany typ
    5. Uruchom doctesty - wszystkie muszą się powieść

Hints:
    * Merge dict since Python 3.9: `dict1 | dict2`
    * Merge dict in Python 3.7, 3.8: `{**dict1, **dict2)}`
    * Convert args into dict: `dict(zip(func.__annotations__.keys(), args))`
    * `echo.__annotations__`
    # {'a': <class 'str'>,
    #  'b': <class 'int'>,
    #  'c': <class 'float'>,
    #  'return': <class 'bool'>}
    * `dict(zip(...))`
    * `kwargs.items()`
    * `list(kwargs.items())[:-1]`
    * `dict1 | dict2` - merging dicts since Python 3.9

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isfunction

    >>> assert isfunction(typecheck), \
    'Create typecheck() function'

    >>> assert isfunction(typecheck(lambda: ...)), \
    'typecheck() should take function as an argument'

    >>> @typecheck
    ... def echo(a: str, b: int, c: float = 0.0) -> bool:
    ...     return bool(a * b)

    >>> echo('one', 1)
    True
    >>> echo('one', 1, 1.1)
    True
    >>> echo('one', b=1)
    True
    >>> echo('one', 1, c=1.1)
    True
    >>> echo('one', b=1, c=1.1)
    True
    >>> echo(a='one', b=1, c=1.1)
    True
    >>> echo(c=1.1, b=1, a='one')
    True
    >>> echo(b=1, c=1.1, a='one')
    True
    >>> echo('one', c=1.1, b=1)
    True
    >>> echo(1, 1)
    Traceback (most recent call last):
    TypeError: "a" is <class 'int'>, but <class 'str'> was expected
    >>> echo('one', 'two')
    Traceback (most recent call last):
    TypeError: "b" is <class 'str'>, but <class 'int'> was expected
    >>> echo('one', 1, 'two')
    Traceback (most recent call last):
    TypeError: "c" is <class 'str'>, but <class 'float'> was expected
    >>> echo(b='one', a='two')
    Traceback (most recent call last):
    TypeError: "b" is <class 'str'>, but <class 'int'> was expected
    >>> echo('one', c=1.1, b=1.1)
    Traceback (most recent call last):
    TypeError: "b" is <class 'float'>, but <class 'int'> was expected

    >>> @typecheck
    ... def echo(a: str, b: int, c: float = 0.0) -> bool:
    ...     return str(a * b)
    >>>
    >>> echo('one', 1, 1.1)
    Traceback (most recent call last):
    TypeError: "return" is <class 'str'>, but <class 'bool'> was expected
"""


# type: Callable[[Callable], Callable]
def typecheck(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)

    return wrapper