©2018 Bloomberg Finance L.P.
All rights reserved.

A Taxonomy of Decorators: A-E

Andy Fundinger, Senior Engineer

EuroPython, Edinburgh, Scotland, July 29, 2018

Introduction

  • Who I am
  • What is Bloomberg?
  • What is this talk?
    • we use decorators, but when we go to talk about them it's hard to get them into categories
    • with a common terminology we can discuss more easily
  • Who is this talk for?
    • Intermediate developers who can write decorators, but maybe aren't sure when and why
    • Architects who may need to work across teams to implement and manage their design

Decorator Syntax and Implementation

Any decorator can be replaced with code in the decorated functions. However, decorators allow this code to be reused and factored out of the functions.

  • Decorators without the @ sign
  • Function Decorators
  • Class Decorators
  • Decorators with arguments
  • Decorators written as classes

Generally decorators insert a section of code of arbitrary complexity in a single line. Debuggers generally skip over this code--for better or worse.

Basic Syntax

Decorators without the @ sign

The oldest decorators in Python are @staticmethod and @classmethod. Dating back to Python 2.2 we used those like this:

In [53]:
class Util(object):
    def wibble():
        print("Wobble")
    wibble = staticmethod(wibble)

The @ sign

The @ sign was added in 2.4 as a syntactic sugar for this pattern

In [54]:
class Util(object):
    @staticmethod
    def wibble():
        print("Wobble")
    @null_it
    def disabled():
        pass

We can write our own decorators as long as we accept a function and return a replacement function.

In [1]:
def null_it(func):
    'Replace with no-op'
    def null(*args,**kwargs): pass
    return null

Class Decorators

In Python 2.6 we can decorate classes too,

In [3]:
@null_it
class DeadClass:
    pass

DeadClass() is None
Out[3]:
True

Decorators with arguments

Decorators with arguments aren't actually doing anything all that special. They simply call the function with the arguments and that function returns the actual decorator.

In [70]:
def mult_arg(mult):
    def deco(func):
        def wrapper(arg_one, *args, **kwargs):
            return func(arg_one*mult, *args, **kwargs)
        return wrapper
    return deco

@mult_arg(3)
def print_x(x):
    print(x)
    
print_x(1)
print_x('Hello ')
3
Hello Hello Hello 

Notice, this is two closures, one for the argument (mult) and one for the function (func).

Decorators written as classes

Normally we write decorators that are closures, but there's no particular benefit to this.

In [71]:
import random

class trace_it:
    def __init__(self, func):
        self.func = func
    def __call__(self, *args, **kwargs):
        print(args, kwargs)
        return self.func(*args, **kwargs)
        
@trace_it
def rand(min_val, max_val):
    return random.randint(min_val, max_val)
rand(10,30)
(10, 30) {}
Out[71]:
27

A - Argument Changing Decorators

  • add or remove an argument when the function is called
  • change the value or type of an argument at call time
  • similarly alter the return value

Problems

  • calling the apparent signature does not actually work
  • calling a function for a test requires injecting data to drive the decorator properly

Example: pytest.mark.parametrize()

In [74]:
import pytest

@pytest.mark.parametrize("test_input,expected", [
    ("3+5", 8),
    ("2+4", 6),
    ("6*9", 42),
])

def test_eval(test_input, expected):
    assert eval(test_input) == expected

Example implementation -- adding the func name in the call

In [76]:
def fn_with_name(func):
    def wrapper(*args, **kwargs):
        func(func.__name__,*args,**kwargs)
    return wrapper
        
@fn_with_name
def self_aware(name, oth):
    print('{name}: {oth}'.format(name=name, oth=oth))
    
self_aware('here')
        
self_aware: here

B - Binding Decorators

  • implement the Descriptor Protocol to change how functions behave
  • the standard library includes @staticmethod, @classmethod, and @property

Problems

  • creates an alternative to instance methods and attributes
  • new language patterns arguably better fitting other languages
  • time shifts otherwise normal exceptions or introduces new ones

Example: SQLAlchemy Hybrid Properties

In [78]:
#from sqlalchemy import func
hybrid_property= MagicMock()

class Interval(object):
    @hybrid_property
    def radius(self):
        return abs(self.length) / 2

    @radius.expression
    def radius(cls):
        return func.abs(cls.length) / 2

Example implementation -- instance method

In [34]:
class instance_method:
    def __init__(self, func):
        self.func = func
    def __get__(self, inst, cls):
        if inst is None:
            raise TypeError(f'{self.func.__name__} is only valid on instances.')
        return self.func.__get__(inst, cls)
    
class GoodClass:
    @instance_method
    def simple_method(self):
        print('simple_method')
    def normal_method(self):
        print('normal_method')
In [35]:
GoodClass().normal_method()
GoodClass().simple_method()
normal_method
simple_method
In [32]:
GoodClass.normal_method
Out[32]:
<function __main__.GoodClass.normal_method>
In [40]:
GoodClass.simple_method
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-40-5ffb98a5765b> in <module>()
----> 1 GoodClass.simple_method

<ipython-input-34-53c5e8f52716> in __get__(self, inst, cls)
      4     def __get__(self, inst, cls):
      5         if inst is None:
----> 6             raise TypeError(f'{self.func.__name__} is only valid on instances.')
      7         return self.func.__get__(inst, cls)
      8 

TypeError: simple_method is only valid on instances.

C - Control Flow Decorators

  • change whether a function will be called and how many times

Problems

  • a predictable control flow now has a hidden conditional
  • a single invocation might now lead to 0, 1, or many executions of the function

Example: Retry Decorator

In [39]:
#from retrying import retry
retry = MagicMock()

@retry
def do_something_unreliable():
    if random.randint(0, 10) > 1:
        raise IOError("Broken sauce, everything is hosed!!!111one")
    else:
        return "Awesome sauce!"

print(do_something_unreliable())
<MagicMock name='mock()()' id='139994135494384'>

Example implementation -- infinite retry

In [9]:
def infinite_retry(func):
    def wrapper(*args, **kwargs):
        while True:
            try:
                return func(*args, **kwargs)
            except RuntimeError as e:
                print(e)
    return wrapper

@infinite_retry
def random_fail(max_value):
    ret = random.randint(-100, max_value)
    if ret<0:
        raise RuntimeError("Invalid negative number {ret}".format(ret=ret))
    return ret

random_fail(10)
Invalid negative number -79
Invalid negative number -67
Out[9]:
6

D - Descriptive Decorators

  • add the decorated object to some sort of collection
  • this collection will serve some other purpose such as:
    • documentation
    • dispatching
    • plugins

Problems

  • it's unclear how dispatching will be done as a result of registration
  • similarly it's hard to see where the registration is maintained

Example pytest.marks

In [90]:
#import pytest
pytest = MagicMock()

@pytest.mark.webtest
def test_send_http():
    pass # perform some webtest test for your app
def test_something_quick():
    pass
def test_another():
    pass
class TestClass(object):
    def test_method(self):
        pass

Example: flask.app.route

In [92]:
#app = Flask(__name__)
app = MagicMock()

@app.route('/')
def hello_world():
    return 'Hello, World!'

Example implementation -- qa list

In [97]:
import warnings 

to_qa=[]
def qa(func):
    to_qa.append(func.__name__)
    return func

@qa
def new_code(): pass

@qa
def refactored_code(): pass

def well_trusted_code(): pass
In [98]:
to_qa
Out[98]:
['new_code', 'refactored_code']

E - Execution Decorators

  • reads the method/class code
  • may reinterpret the source code to basically not be python

Problems

  • many
  • and more

This truly means that the code you wrote is changed--by the decorator--to some other code that is then executed. It might:

  • be analyzed for dependencies
  • have objects in the ast swapped out, injected or removed
  • be recompiled with different rules

Example: cython

In [101]:
cython = MagicMock()

@cython.locals(a=cython.double, b=cython.double, n=cython.p_double)
def foo(a, b, x, y):
    n = a*b
    ...

Example implementation -- code replacer

In [103]:
def replacer(old, new):
    def deco(func):
        source = inspect.getsource(func.__code__)
        lines = source.split('\n')
        new_source = lines[1]+'\n'+('\n'.join(lines[2:]).replace(old, new))
        exec(new_source,globals())
        return globals()[func.__name__]
    return deco
In [102]:
def sample(a, b):
    x = a + b
    y = x * 2
    print('Sample: ' + str(y))
sample(1,4)
Sample: 10
In [105]:
@replacer('b','b*3')
def sample(a, b):
    x = a + b
    y = x * 2
    print('Sample: ' + str(y))
sample(1,4)
Sample: 26
In [106]:
@replacer('a','a*3')
def sample(a, b):
    x = a + b
    y = x * 2
    print('Sample: ' + str(y))
sample(1,4)
Sa*3mple: 14

Other tools available for execution decorators

  • bytecode manipulation
  • ast manipulation

Conclusion

Our Taxonomy

  • Argument changing
    • @click.option
    • @flask.templated
    • @django.views.decorators.gzip.gzip_page
  • Binding
    • @variants.primary
    • @pyramid.decorator.reify
  • Control flow
    • @functools.lru_cache
    • @django.views.decorators.http.require_http_methods
    • @twisted.internet.defer.inlineCallbacks
  • Descriptive
    • @numpy.testing.decorators.setastest
  • Execution
    • @numba.jit
  • ?