Decorators, Dataclasses and IDEs¶

I'll talk through some common tips for writing cleaner code and common Python patterns.

Decorators¶

Decorating Functions¶

Basic Example¶

We define a simple function which adds an exclamation to a string and returns it.

There are often examples which start from functions which simply print to the console but for our purposes we will most likely be returning values, so let's start there.

In [1]:
def exclamation(string):
    return string + "!"
In [2]:
exclamation('hello')
Out[2]:
'hello!'

Now let's define a decorator function which adds another exclamation mark.

In [3]:
import functools
def emphasis(func):
    @functools.wraps(func) # optional
    def wrapper(*args, **kwargs):
        value = func(*args, **kwargs) # this value is the original "hello!"
        return value + "!"         # let's add that "!"
    # this wrapper_do_twice is a function...
    # ...which takes a function and returns it's string return value, with an additional "!"
    return wrapper
In [4]:
@emphasis
def exclamation_exclamation(*args, **kwargs):
    return exclamation(*args, **kwargs)
In [5]:
exclamation('hello')
Out[5]:
'hello!'
In [6]:
exclamation_exclamation(string = 'hello')
Out[6]:
'hello!!'

We see the extra exclamation mark!!

Okay, we've had a lot of fun there but now we understand the basic concept let's move on to a more substantive example.

Timing Decorator¶

In [7]:
import datetime

def timing(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start_time = datetime.datetime.now()
        result = func(*args, **kwargs)
        end_time = datetime.datetime.now()
        time_string = \
f"""Function Name:
{func.__name__}

Args:
{args, kwargs}

Start Time:
{start_time}

End Time:
{end_time}

Time Taken:
{(end_time-start_time).total_seconds()} seconds.
"""
        print(time_string)
        return result
    return wrapper
In [8]:
exclamation('hello')
Out[8]:
'hello!'
In [9]:
@timing
def exclamation_timed(string):
    return string + "!"
In [10]:
exclamation_timed('hello')
Function Name:
exclamation_timed

Args:
(('hello',), {})

Start Time:
2019-12-11 23:23:38.582135

End Time:
2019-12-11 23:23:38.582142

Time Taken:
7e-06 seconds.

Out[10]:
'hello!'

Logging Decorator¶

Decorator functions work best when you're centralising some form of complexity, this can increase readability and make subsequent refactoring easier.

Now let's move to a more substantive example... logging!

In [11]:
import logging
logger = logging.getLogger('example_logger')
logging.basicConfig(format='%(asctime)s - %(message)s', level=logging.INFO)
logger.warning('This is an only an example warning - relax')
2019-12-11 23:23:38,593 - This is an only an example warning - relax
In [12]:
def logging_decorator(func):
    """This decorator logs functions.
    """
    @functools.wraps(func) # optional but recommended way of preserving func name and doc string.
    def logged_function(*args, **kwargs):
        """This function is the logged function.
        All it does is add the name of the function to the logging stream.
        """
        logger.info(f'running {func.__name__}...')
        return func(*args, **kwargs)
    return logged_function
In [13]:
@logging_decorator
def exclamation(string):
    return string + "!"
In [14]:
exclamation('hello')
2019-12-11 23:23:38,619 - running exclamation...
Out[14]:
'hello!'
In [15]:
@logging_decorator
def something_else(string):
    """Adds a question mark to a string and returns it."""
    return string + "?"
In [16]:
something_else('hello')
2019-12-11 23:23:38,637 - running something_else...
Out[16]:
'hello?'

We can do this because... functions in Python are themselves objects.
Therefore all functions inherit methods from the base function class.
Let's have a look at some of them.

In [17]:
something_else.__class__
Out[17]:
function
In [18]:
something_else.__name__
Out[18]:
'something_else'
In [19]:
print('You can even look at the docstring...')
print(something_else.__doc__)
You can even look at the docstring...
Adds a question mark to a string and returns it.

Decorating classes¶

Decorating the whole class¶

Let's see if we can take this up a level.
Often you'll be working with classes you have defined, so let's see how we can decorate these.
Clue: we'll be using similar private methods as we used for the function example just now.

In [20]:
class GreetingClass():
    def __init__(self, name):
        self.name = name
        
    def hello(self):
        return f"Hello {self.name}"
In [21]:
test_greeting = GreetingClass('Daniel')
test_greeting.hello()
Out[21]:
'Hello Daniel'

Let's look behind the scenes of this class.

In [22]:
GreetingClass.__dict__
Out[22]:
mappingproxy({'__module__': '__main__',
              '__init__': <function __main__.GreetingClass.__init__(self, name)>,
              'hello': <function __main__.GreetingClass.hello(self)>,
              '__dict__': <attribute '__dict__' of 'GreetingClass' objects>,
              '__weakref__': <attribute '__weakref__' of 'GreetingClass' objects>,
              '__doc__': None})

It's got a dictionary* with the keys are methods and values are the functions themselves!

* It's actually a mapping proxy object, which is a lightweight dictionary which doesn't support item assignment, hence we use setattr() below.

In [23]:
def logging_the_class(cls):
    for key, func in cls.__dict__.items():
        if key.startswith("__") and key.endswith("__") or not callable(func):
            # we don't care about decorating __init__ or any private methods
            continue
        # below is the key line, it sets the function with setattr to the logged function
        setattr(cls, key, logging_decorator(func))
        print("Wrapped", key)
    return cls
In [24]:
@logging_the_class
class GreetingClass():
    def __init__(self, name):
        self.name = name
        
    def hello(self):
        return f"Hello {self.name}"
    
    def goodbye(self):
        return f"Goodbye {self.name}"
Wrapped hello
Wrapped goodbye
In [25]:
test_greeting = GreetingClass('Daniel')
In [26]:
test_greeting.hello()
2019-12-11 23:23:38,747 - running hello...
Out[26]:
'Hello Daniel'
In [27]:
test_greeting.goodbye()
2019-12-11 23:23:38,757 - running goodbye...
Out[27]:
'Goodbye Daniel'

Further logging is out of the scope of this talk, but you could do things like dump your logs to a file, or define a decorator which is itself a class (and therefore stores state ) which adds the number of times the function is called to the logging stream.

Decorating a method¶

This is final decorator example and it's a bit more complex.
We're decorating a single method.

The main issue we have, how do we access .self?

First let's see what we do with the loggingdecorator() to make it work for a single method.

In [28]:
def logging_decorator(func):
    """This decorator logs functions.
    """
    @functools.wraps(func)
    def logged_function(*args, **kwargs):
        """This function is the logged function.
        All it does is add the name of the function to the logging stream.
        """
        self = args[0]
        logger.info(f'running {func.__name__}...')
        return func(*args, **kwargs)
    return logged_function
In [29]:
def logging_decorator(func):
    """This decorator logs functions.
    """
    @functools.wraps(func) # optional but recommended way of preserving func name and doc string.
    def logged_function(*args, **kwargs):
        """This function is the logged function.
        All it does is add the name of the function to the logging stream.
        """
        self = args[0]
        logger.info(f'running {func.__name__}...')
        return func(*args, **kwargs)
    return logged_function
In [30]:
class NewGreetingClass():
    def __init__(self, name):
        self.name = name
        
    def hello(self):
        return f"Hello {self.name}"
    
    @logging_decorator
    def goodbye(self):
        return f"Goodbye {self.name}"
In [31]:
new_greeting = NewGreetingClass('Dan')
In [32]:
# no logging
new_greeting.hello()
Out[32]:
'Hello Dan'
In [33]:
# logged
new_greeting.goodbye()
2019-12-11 23:23:38,814 - running goodbye...
Out[33]:
'Goodbye Dan'

Dataclasses¶

Dataclasses are a convenient way to create classes without nearly as much boilerplate code.

Why?¶

In [2]:
from dataclasses import dataclass
In [3]:
class NewGreetingClass():
    def __init__(self, name):
        self.name: str = name
        
    def hello(self):
        return f"Hello {self.name}"
    
    def goodbye(self):
        return f"Goodbye {self.name}"

To define the simple class above, we have had to type name three times.
There has to be a better way...

In [4]:
@dataclass
class DCNewGreetingClass:
    name: str
    
    def hello(self):
        return f"Hello {self.name}"

    def goodbye(self):
        return f"Goodbye {self.name}"

This pattern allows us to define a class and have its 'init' function generated for us.
This can be especially useful for large classes with many paramaters.

However there is downside...
...as of Python 3.7 Dataclasses do not support properties.

But we can get round this with

In [5]:
@dataclass
class NewGreetingClassStructure:
    _name: str
    
    @property
    def name(self):
        print('Getting name...')
        return f"{self._name}"
    
    @name.setter
    def name(self, new_name):
        print('Setting name...')
        self._name = new_name
In [6]:
test = NewGreetingClassStructure('Daniel')
In [7]:
test.name = 'Dan'
Setting name...

Immutable Dataclasses¶

In [8]:
@dataclass(frozen=True)
class ImmutableGreetingClass():
    name: str
    
    def hello(self):
        return f"Hello {self.name}"

    def goodbye(self):
        return f"Goodbye {self.name}"
In [9]:
immutable_test = ImmutableGreetingClass(name='Dan')
In [10]:
immutable_test.name = 'Daniel'
---------------------------------------------------------------------------
FrozenInstanceError                       Traceback (most recent call last)
<ipython-input-10-89ee780836ec> in <module>
----> 1 immutable_test.name = 'Daniel'

<string> in __setattr__(self, name, value)

FrozenInstanceError: cannot assign to field 'name'

Immutability in dataclasses is that simple.

As your class gets bigger, you may want to have a dataclass that you define all parameters in.
Then you can simply inherit from this class to define your methods.

The advantages of IDEs¶

Working with these features in a notebook is possible, but let's close with a look at some of the advantages of an IDE such as Pycharm.

Sources¶

  1. Advanced Decorators
    • http://archive.oreilly.com/oreillyschool/courses/Python4/Python4-07.html#class_decorators
  2. Logging
    • https://realpython.com/python-logging/