7.2. Decorator Function with Func

7.2.1. Rationale

Syntax:
@mydecorator
def myfunction(*args, **kwargs):
    ...
Is equivalent to:
myfunction = mydecorator(myfunction)

7.2.2. Syntax

  • Decorator must return pointer 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()

7.2.3. 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'

7.2.4. Use Cases

File exists:

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

Timeit:

from datetime import datetime


def timeit(func):
    def wrapper(*args, **kwargs):
        start = datetime.now()
        result = func(*args, **kwargs)
        end = datetime.now()
        print(f'Duration: {end-start}')
        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

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

Stacked decorators:

from datetime import datetime
import logging

logging.basicConfig(
    level='DEBUG',
    datefmt='"%Y-%m-%d", "%H:%M:%S"',
    format='{asctime}, "{levelname}", "{message}"',
    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"

7.2.5. Scope

Recap information about factorial (n!):

"""
5! = 5 * 4!
4! = 4 * 3!
3! = 3 * 2!
2! = 2 * 1!
1! = 1 * 0!
0! = 1
"""

factorial(5)                                    # = 120
    return 5 * factorial(4)                     # 5 * 24 = 120
        return 4 * factorial(3)                 # 4 * 6 = 24
            return 3 * factorial(2)             # 3 * 2 = 6
                return 2 * factorial(1)         # 2 * 1 = 2
                    return 1 * factorial(0)     # 1 * 1 = 1
                        return 1                # 1
def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n-1)

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}

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

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}

7.2.6. Example

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'},
    'bjohansen':    {'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'}

Flask URL Routing:

from flask import json
from flask import Response
from flask import render_template
from flask import Flask

app = Flask(__name__)


@app.route('/summary')
def summary():
    data = {'firstname': 'Jan', 'lastname': 'Twardowski'}
    return Response(
        response=json.dumps(data),
        status=200,
        mimetype='application/json')


@app.route('/post/<int:pk>')
def post(pk):
    post = ... # get post from Database by pk
    return render_template('post.html', post=post)


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

FastAPI URL routing:

from typing import Optional
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: Optional[str] = None):
    return {'q': q}

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')

7.2.7. Assignments

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

English:
    1. Create decorator `mydecorator`
    2. Decorator should have `wrapper` with `*args` and `**kwargs` parameters
    3. Wrapper should call original function with it's original parameters,
       and return its value
    4. Decorator should return `wrapper` function
    5. Run doctests - all must succeed

Polish:
    1. Stwórz dekorator `mydecorator`
    2. Dekorator powinien mieć `wrapper` z parametrami `*args` i `**kwargs`
    3. Wrapper powinien wywoływać oryginalną funkcję z jej oryginalnymi
       parametrami i zwracać jej wartość
    4. Decorator powinien zwracać funckję `wrapper`
    5. Uruchom doctesty - wszystkie muszą się powieść

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

    >>> assert isfunction(mydecorator)
    >>> assert isfunction(mydecorator(lambda: None))

    >>> @mydecorator
    ... def echo(text):
    ...     return text

    >>> echo('hello')
    'hello'
"""


Code 7.2. Solution
"""
* Assignment: Decorator Function Disable
* Complexity: easy
* Lines of code: 1 lines
* Time: 5 min

English:
    1. Modify decorator `disable`
    2. Decorator raises an exception `PermissionError` and does not execute function
    3. Run doctests - all must succeed

Polish:
    1. Zmodyfikuj dekorator `disable`
    2. Dekorator podnosi wyjątek `PermissionError` i nie wywołuje funkcji
    3. Uruchom doctesty - wszystkie muszą się powieść

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

    >>> assert isfunction(disable)
    >>> assert isfunction(disable(lambda: None))

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

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

def disable(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


Code 7.3. 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 pointers 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ą wskaźniki 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

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

    >>> from inspect import isfunction
    >>> assert isfunction(check)
    >>> assert isfunction(check(lambda: None))
    >>> assert isfunction(echo)

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

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

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

def check(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


Code 7.4. 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 członkowie załogi 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)
    >>> assert isfunction(check_astronauts(lambda: None))

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

    >>> launch(CREW_PRIMARY)
    'Launching: Jan 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': 'Jan 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'}]


def check_astronauts(func):
    def wrapper(crew):
        return func(crew)
    return wrapper


Code 7.5. 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 computed value
    3. Compare execution time using `timeit` (it might take around 30 seconds)
    4. Last three tests will fail, this is only infomation about execution time
    5. Run doctests - all must succeed

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, to zwraca dane z `_cache`
        b. jeżeli nie, to oblicza, aktualizuje `_cache`, a następnie zwraca wartość
    3. Porównaj prędkość działania za pomocą `timeit` (może to trwać około 30 sekund)
    4. Ostatnie trzy testy nie przejdą, to tylko informacja o czasie wykonywania
    5. Uruchom doctesty - wszystkie muszą się powieść

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

    >>> @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)

    >>> duration_cache = timeit(stmt='fn1(500); fn1(400); fn1(450); fn1(350)',
    ...                         globals=globals(), number=10_000)

    >>> duration_nocache = timeit(stmt='fn2(500); fn2(400); fn2(450); fn2(350)',
    ...                           globals=globals(), number=10_000)

    >>> duration_ratio = duration_nocache / duration_cache
    >>> print(f'With Cache time: {duration_cache:.4f} seconds')
    >>> print(f'Without Cache time: {duration_nocache:.3f} seconds')
    >>> print(f'Cached solution is {duration_ratio:.1f} times faster')

    TODO: Make tests faster
"""

_cache = {}


def cache(func):
    def wrapper(n):
        return func(n)
    return wrapper


Code 7.6. 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. 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. Uruchom doctesty - wszystkie muszą się powieść

Hints:
    * `path = Path(CURRENT_DIR, filename)`

Tests:
    >>> import sys; sys.tracebacklimit = 0

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

    >>> display('iris.csv').startswith(str(CURRENT_DIR))
    True
    >>> display('iris.csv').endswith('iris.csv')
    True
    >>> display('/home/python/iris.csv')
    '/home/python/iris.csv'
"""

from pathlib import Path


CURRENT_DIR = Path().cwd()


Code 7.7. 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)
    >>> assert isfunction(numeric(lambda: None))

    >>> @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
"""

def numeric(func):
    def wrapper(a, b):
        return func(a, b)
    return wrapper


Code 7.8. Solution
"""
* Assignment: Decorator Function Type Check
* 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. In case when received type is not expected throw an exception `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. W przypadku gdy otrzymany typ nie jest równy oczekiwanemu wyrzuć wyjątek `TypeError` z:
        a. nazwa argumentu
        b. aktualny typ
        c. oczekiwany typ
    5. Uruchom doctesty - wszystkie muszą się powieść

Hints:
    * `echo.__annotations__`
    # {'a': <class 'str'>,
    #  'b': <class 'int'>,
    #  'c': <class 'float'>,
    #  'return': <class 'bool'>}

Tests:
    >>> import sys; sys.tracebacklimit = 0

    >>> @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
"""

def typecheck(func):
    def merge(*args, **kwargs):
        """Function merges *args, and **kwargs into single dict"""
        args = dict(zip(func.__annotations__.keys(), args))
        return kwargs | args          # Python 3.9
        # return {**args, **kwargs)}  # Python 3.7, 3.8

    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper