Decorators That Don't (Didn't) Mix: @abstractmethod and @cachedproperty


In [1]:
__author__ = 'Matt Wilber'

In [2]:
import sys

print(sys.version)


3.5.6 (default, Oct 29 2018, 15:18:57) 
[GCC 4.2.1 Compatible Apple LLVM 10.0.0 (clang-1000.10.44.2)]

Overview

Recently I ran into an issue where two of my favorite Python decorators, @abstractmethod and @cachedproperty, weren't playing well together. This post covers that specific issue, as well as more general ways to avoid this problem when writing your own decorators.

Background

Intro to @abstractmethod

The @abstractmethod decorator from Python 3's built-in abc module is a nice way to enforce proper implementation of an interface. It will raise a TypeError when you call __init__ for a class that has unimplemented abstract methods.


In [3]:
from abc import abstractmethod, ABC


class AbstractPouncer(ABC):
    
    @abstractmethod
    def pounce(self):
        pass
    
    
class Fox(AbstractPouncer):
    
    def pounce(self):
        self.crouch()
        self.leap()
        self.attack()
    
    def crouch(self):
        print('Crouch crouch crouch...')
        
    def leap(self):
        print('Wheeee!')
        
    def attack(self):
        print('I GOTCHU 🦊')

In [4]:
try:
    AbstractPouncer()
except TypeError as e:
    print('TypeError:', e)


TypeError: Can't instantiate abstract class AbstractPouncer with abstract methods pounce

In [5]:
fox = Fox()
fox.pounce()


Crouch crouch crouch...
Wheeee!
I GOTCHU 🦊

Intro to @cachedproperty

The @cachedproperty decorator is a less common decorator that isn't part of Python 3's builtins (until Python 3.8!). There are a bunch of implementations out there apparently (Django, Pyramid, astroid, boltons, ...) but for our purposes we'll use the one from boltons, which I've been using for my own projects. In any case, all the ones I looked at had the issue we're about to address.

(Note: This issue is now fixed in boltons https://github.com/mahmoud/boltons/pull/184)


In [6]:
class cachedproperty:
    """The ``cachedproperty`` is used similar to :class:`property`, except
    that the wrapped method is only called once. This is commonly used
    to implement lazy attributes.

    After the property has been accessed, the value is stored on the
    instance itself, using the same name as the cachedproperty. This
    allows the cache to be cleared with :func:`delattr`, or through
    manipulating the object's ``__dict__``.

    Copied from https://github.com/mahmoud/boltons/blob/master/boltons/cacheutils.py on 9/17/18
    """

    def __init__(self, func):
        self.__doc__ = getattr(func, '__doc__')
        self.func = func

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = obj.__dict__[self.func.__name__] = self.func(obj)
        return value

    def __repr__(self):
        cn = self.__class__.__name__
        return '<%s func=%s>' % (cn, self.func)

Example of using both: Providing a process_time

I write a lot of data pipelines, and it can be useful to have a process be consistent about all the times it uses (e.g., to set a creation_time field for a record). A @cachedproperty is a nice way to ensure we always get the same time back!


In [7]:
from datetime import datetime
from typing import Iterator


class ProcessTimeProvider(ABC):
    """Abstract interface for providing times for data pipelines + other processes"""
    
    @cachedproperty
    @abstractmethod
    def process_time(self) -> datetime:
        pass
    
    
class LocalProcessTimeProvider(ProcessTimeProvider):
    
    @cachedproperty
    def process_time(self) -> datetime:
        return datetime.now()
    
    
class UTCProcessTimeProvider(ProcessTimeProvider):
    
    @cachedproperty
    def process_time(self) -> datetime:
        return datetime.utcnow()

For the sake of completeness, let's see an example of how these might work. The code below counts the # of times the 🦊 character appears in a text.


In [8]:
class FoxMention:
    
    def __init__(self, offset: int, creation_time: datetime):
        self.offset = offset
        self.creation_time = creation_time
        
    def __repr__(self):
        return '<FoxMention(offset={}, creation_time={})>'.format(self.offset, self.creation_time)

        
class FoxExtractionProcess(UTCProcessTimeProvider):
    """Counts 🦊s!"""
        
    def extract_foxes(self, text) -> Iterator[FoxMention]:
        for offset, character in enumerate(text):
            if character == '🦊':
                yield FoxMention(
                    offset=offset,
                    creation_time=self.process_time
                )

In [9]:
fox_extractor = FoxExtractionProcess()
text = 'The quick brown 🦊 jumps over the lazy 🦊'
for fox_mention in fox_extractor.extract_foxes(text):
    print(fox_mention)


<FoxMention(offset=16, creation_time=2019-01-31 00:22:49.722517)>
<FoxMention(offset=38, creation_time=2019-01-31 00:22:49.722517)>

This use case for @abstractmethod, combined with @cachedproperty, is great! I like to use them in combination all the time. But here's the issue:

The Problem


In [10]:
ProcessTimeProvider()


Out[10]:
<__main__.ProcessTimeProvider at 0x10fc9f4a8>

Wait – isn't ProcessTimeProvider an abstract class that hasn't had all its methods implemented? As we learned above, this should throw a TypeError when we try to instantiate the abstract class. This was baffling to me at first, and I had to understand a little more about how Python decorators and abstract methods are implemented to fix it.

Going deeper: How @abstractmethod works

The current implementation of @abstractmethod is surprisingly simple:

def abstractmethod(funcobj):
    funcobj.__isabstractmethod__ = True
    return funcobj

It takes the function it is decorating, and sets an attribute (yes, functions can have attributes!) called __isabstractmethod__ to True. All right, let's check for that:


In [11]:
print(getattr(AbstractPouncer.pounce, '__isabstractmethod__', None))
print(getattr(ProcessTimeProvider.process_time, '__isabstractmethod__', None))


True
None

We're onto something! In AbstractPouncer.pounce, which didn't have a @cachedproperty annotation, we see __isabstractmethod__ is set, as expected. So what's happening in ProcessTimeProvider.process_time? It must be related to @cachedproperty. Let's look at the implementation again.

Looking closer at @cachedproperty


In [12]:
class cachedproperty:

    def __init__(self, func):
        self.__doc__ = getattr(func, '__doc__')
        self.func = func

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = obj.__dict__[self.func.__name__] = self.func(obj)
        return value

    def __repr__(self):
        cn = self.__class__.__name__
        return '<%s func=%s>' % (cn, self.func)

So if __isabstractmethod__ isn't in ProcessTimeProvider.process_time, what happened to it? Let's inspect the process_time method a little more.


In [13]:
print(ProcessTimeProvider.process_time)
print(ProcessTimeProvider.process_time.__dict__)


<cachedproperty func=<function ProcessTimeProvider.process_time at 0x10fccbf28>>
{'__doc__': None, 'func': <function ProcessTimeProvider.process_time at 0x10fccbf28>}

It looks like the cachedproperty decorator has changed the top-level process_time into a cachedproperty object, which contains a func attribute that is the original process_time object! Is that where __isabstractmethod__ could be hiding?


In [14]:
print(ProcessTimeProvider.process_time.func.__dict__)


{'__isabstractmethod__': True}

Aha! So the __isabstractmethod__ hasn't disappeared at all! It's just been wrapped by @cachedproperty into the func attribute of the method. But that's not where Python 3 expects it to be. That should be an easy fix.

A Fix!


In [15]:
class cachedproperty:

    def __init__(self, func):
        self.__doc__ = getattr(func, '__doc__')
        self.__isabstractmethod__ = func.__isabstractmethod__ # The fix!
        self.func = func

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = obj.__dict__[self.func.__name__] = self.func(obj)
        return value

    def __repr__(self):
        cn = self.__class__.__name__
        return '<%s func=%s>' % (cn, self.func)
    
    
class AbstractTimeProvider(ABC):
    
    @cachedproperty
    @abstractmethod
    def time(self):
        pass

In [16]:
try:
    AbstractTimeProvider()
except TypeError as e:
    print('TypeError:', e)


TypeError: Can't instantiate abstract class AbstractTimeProvider with abstract methods time

Voilà! Now we can declare abstract cached properties with all the benefits of Python's abc module. As noted above, this is now fixed in the newest version of boltons.

A More Generic Solution

This problem likely affects all sorts of of decorators, not just cachedproperty, as well as other attributes beyond __isabstractmethod__. Luckily, Python's functools actually has a built-in solution, @wraps, for helping wrapper functions maintain important attributes of the original function:


In [17]:
from functools import wraps

def bad_decorator(func):
    def wrapper(num):
        return func(num)
    
    return wrapper


def good_decorator(func):
    @wraps(func)
    def wrapper(num):
        return func(num)
    
    return wrapper


class BadTwoAdder(ABC):
    @bad_decorator
    @abstractmethod
    def bad_add_two(num):
        """Add two to a number"""
        return 2 + num


class GoodTwoAdder(ABC):
    @good_decorator
    @abstractmethod
    def good_add_two(num):
        """Add two to a number"""
        return 2 + num

Both bad_decorator and good_decorator above make no real modifications to the functions they wrap, but the way they wrap is different. Only when using @wraps are important function attributes maintained.


In [18]:
print("Badly wrapped name:", BadTwoAdder.bad_add_two.__name__)
print("Badly wrapped docstring:", BadTwoAdder.bad_add_two.__doc__)
print("Badly wrapped __isabstractmethod__:", getattr(BadTwoAdder.bad_add_two, '__isabstractmethod__', None))

print()

print("Well wrapped name:", GoodTwoAdder.good_add_two.__name__)
print("Well wrapped docstring:", GoodTwoAdder.good_add_two.__doc__)
print("Well wrapped __isabstractmethod__:", getattr(GoodTwoAdder.good_add_two, '__isabstractmethod__', None))


Badly wrapped name: wrapper
Badly wrapped docstring: None
Badly wrapped __isabstractmethod__: None

Well wrapped name: good_add_two
Well wrapped docstring: Add two to a number
Well wrapped __isabstractmethod__: True

The output from GoodTwoAdder looks much better! When writing your own function-wrapping decorators, make sure to put @wraps to good use!