In [1]:
__author__ = 'Matt Wilber'
In [2]:
import sys
print(sys.version)
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)
In [5]:
fox = Fox()
fox.pounce()
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)
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)
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:
In [10]:
ProcessTimeProvider()
Out[10]:
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.
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))
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.
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__)
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__)
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.
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)
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.
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))
The output from GoodTwoAdder
looks much better! When writing your own function-wrapping decorators, make sure to put @wraps
to good use!