Decorators

The Ominous “@

  • Special syntax ⟶ only syntactic sugar

  • Used to wrap existing functions/methods

    • Without modifying them

  • Examples

    • @functools.total_ordering (see here)

    • Debugging: printing function name and arguments

    • Timing information: time spent inside a function

    • Adding a D-Bus interface to a plain Python class

    • Defining Flask URL routes to call Python functions

Decorators Overview

  • Only syntactic sugar for inner functions/closures (see Closures)

  • Can be implemented as functions

@debug
def bar(a, b, c):
    ...
  • Can be implemented as classes (usually when decorator is parameterized ⟶ state needs to be remembered)

@debug(prefix)
def bar(a, b, c):
    ...

A Simple Minded Function

def f():
    print('f called (inside the function)')
    return 42

f()
f called (inside the function)
42

Decorator Basics

  • Something that creates a function to wrap another function

  • Wrapped function passed as argument

  • That is the whole point

def debug(func):
    def wrapper():
        print(func.__name__, 'called (says wrapper)')
        return func()
    return wrapper

Decorators are Syntactic Sugar

  • Lets decorate f()

f = debug(f)
f()
f called (says wrapper)
f called (inside the function)
42
  • This is too much typing

  • Why explicitly replace a function?

  • Want decadence!

  • @debug is the same as f = debug(f)!

@debug
def f():
    return 42
f()
f called (says wrapper)
42

Problem: Arbitrary Function Arguments

  • Currently, wrapper() cannot does not take any arguments

  • ⟶ cannot wrap any function

@debug
def add(a, b):         # <--- takes 2 arguments
    return a+b

add(1,2)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[6], line 5
      1 @debug
      2 def add(a, b):         # <--- takes 2 arguments
      3     return a+b
----> 5 add(1,2)

TypeError: debug.<locals>.wrapper() takes 0 positional arguments but 2 were given

*args, **kwargs To The Rescue

  • A wrapper needs to accept anything that the wrapped function can take

  • As generic as possible

  • starargs

def debug(func):
    def wrapper(*args, **kwargs):       # <--- accept anything
        print(func.__name__, 'called:', args, kwargs)
        return func(*args, **kwargs)    # <--- pass anything
    return wrapper
  • wrapper() can wrap anything

  • Can @debug anything now

@debug
def add(a, b):
    return a+b

add(1,2)
add called: (1, 2) {}
3

Sideways: functools.wraps

  • Ugly: wrapper’s __name__ attribute unreadable

  • It’s just wrapper inside local scope

add.__name__
'wrapper'
  • Fix: copy wrapped function’s metadata over

  • @functools.wraps: a decorator for decorators

import functools

def debug(func):
    @functools.wraps(func)           # <--- copy func metadata over to wrapper
    def wrapper(*args, **kwargs):
        print(func.__name__, 'called:', args, kwargs)
        return func(*args, **kwargs)
    return wrapper
  • Now any decorated/wrapped function has all it needs

@debug
def add(a, b):
    return a+b

add.__name__
'add'

Class Decorator: debug() with prefix

import functools

class debug:
    def __init__(self, msg):
        self.msg = msg

    def __call__(self, func):        # <--- __call__() implements () (calls) on objects of "class debug"
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            print(f'{self.msg}: func = {func.__name__}, {args}, {kwargs}')
            return func(*args, **kwargs)
        return wrapper

@debug('wtf')                        # <--- here
def add(l, r):
    return l+r

@debug('gosh')                       # <--- and here
def sub(l, r):
    return l-r

print('add(1,2) = ', add(1,2))
print('sub(1,2) = ', sub(1,2))
wtf: func = add, (1, 2), {}
add(1,2) =  3
gosh: func = sub, (1, 2), {}
sub(1,2) =  -1