From d658deac6060ee92b449a3bf424b460eafd99f3e Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 28 Aug 2018 01:11:56 -0600 Subject: [PATCH] bpo-21145: Add cached_property decorator in functools (#6982) Robust caching of calculated properties is harder than it looks at first glance, so add a solid, well-tested implementation to the standard library. --- Doc/library/functools.rst | 33 ++++ Lib/functools.py | 55 ++++++ Lib/test/test_functools.py | 166 ++++++++++++++++++ .../2018-05-18-22-52-34.bpo-21145.AiQMDx.rst | 2 + 4 files changed, 256 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2018-05-18-22-52-34.bpo-21145.AiQMDx.rst diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 5e278f9fe98..1b94f339600 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -20,6 +20,39 @@ function for the purposes of this module. The :mod:`functools` module defines the following functions: +.. decorator:: cached_property(func) + + Transform a method of a class into a property whose value is computed once + and then cached as a normal attribute for the life of the instance. Similar + to :func:`property`, with the addition of caching. Useful for expensive + computed properties of instances that are otherwise effectively immutable. + + Example:: + + class DataSet: + def __init__(self, sequence_of_numbers): + self._data = sequence_of_numbers + + @cached_property + def stdev(self): + return statistics.stdev(self._data) + + @cached_property + def variance(self): + return statistics.variance(self._data) + + .. versionadded:: 3.8 + + .. note:: + + This decorator requires that the ``__dict__`` attribute on each instance + be a mutable mapping. This means it will not work with some types, such as + metaclasses (since the ``__dict__`` attributes on type instances are + read-only proxies for the class namespace), and those that specify + ``__slots__`` without including ``__dict__`` as one of the defined slots + (as such classes don't provide a ``__dict__`` attribute at all). + + .. function:: cmp_to_key(func) Transform an old-style comparison function to a :term:`key function`. Used diff --git a/Lib/functools.py b/Lib/functools.py index b3428a4ca28..51048f5946c 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -868,3 +868,58 @@ def _method(*args, **kwargs): @property def __isabstractmethod__(self): return getattr(self.func, '__isabstractmethod__', False) + + +################################################################################ +### cached_property() - computed once per instance, cached as attribute +################################################################################ + +_NOT_FOUND = object() + + +class cached_property: + def __init__(self, func): + self.func = func + self.attrname = None + self.__doc__ = func.__doc__ + self.lock = RLock() + + def __set_name__(self, owner, name): + if self.attrname is None: + self.attrname = name + elif name != self.attrname: + raise TypeError( + "Cannot assign the same cached_property to two different names " + f"({self.attrname!r} and {name!r})." + ) + + def __get__(self, instance, owner): + if instance is None: + return self + if self.attrname is None: + raise TypeError( + "Cannot use cached_property instance without calling __set_name__ on it.") + try: + cache = instance.__dict__ + except AttributeError: # not all objects have __dict__ (e.g. class defines slots) + msg = ( + f"No '__dict__' attribute on {type(instance).__name__!r} " + f"instance to cache {self.attrname!r} property." + ) + raise TypeError(msg) from None + val = cache.get(self.attrname, _NOT_FOUND) + if val is _NOT_FOUND: + with self.lock: + # check if another thread filled cache while we awaited lock + val = cache.get(self.attrname, _NOT_FOUND) + if val is _NOT_FOUND: + val = self.func(instance) + try: + cache[self.attrname] = val + except TypeError: + msg = ( + f"The '__dict__' attribute on {type(instance).__name__!r} instance " + f"does not support item assignment for caching {self.attrname!r} property." + ) + raise TypeError(msg) from None + return val diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 10bf0a2aa89..200a5eb4955 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2313,5 +2313,171 @@ def f(*args): with self.assertRaisesRegex(TypeError, msg): f() + +class CachedCostItem: + _cost = 1 + + def __init__(self): + self.lock = py_functools.RLock() + + @py_functools.cached_property + def cost(self): + """The cost of the item.""" + with self.lock: + self._cost += 1 + return self._cost + + +class OptionallyCachedCostItem: + _cost = 1 + + def get_cost(self): + """The cost of the item.""" + self._cost += 1 + return self._cost + + cached_cost = py_functools.cached_property(get_cost) + + +class CachedCostItemWait: + + def __init__(self, event): + self._cost = 1 + self.lock = py_functools.RLock() + self.event = event + + @py_functools.cached_property + def cost(self): + self.event.wait(1) + with self.lock: + self._cost += 1 + return self._cost + + +class CachedCostItemWithSlots: + __slots__ = ('_cost') + + def __init__(self): + self._cost = 1 + + @py_functools.cached_property + def cost(self): + raise RuntimeError('never called, slots not supported') + + +class TestCachedProperty(unittest.TestCase): + def test_cached(self): + item = CachedCostItem() + self.assertEqual(item.cost, 2) + self.assertEqual(item.cost, 2) # not 3 + + def test_cached_attribute_name_differs_from_func_name(self): + item = OptionallyCachedCostItem() + self.assertEqual(item.get_cost(), 2) + self.assertEqual(item.cached_cost, 3) + self.assertEqual(item.get_cost(), 4) + self.assertEqual(item.cached_cost, 3) + + def test_threaded(self): + go = threading.Event() + item = CachedCostItemWait(go) + + num_threads = 3 + + orig_si = sys.getswitchinterval() + sys.setswitchinterval(1e-6) + try: + threads = [ + threading.Thread(target=lambda: item.cost) + for k in range(num_threads) + ] + with support.start_threads(threads): + go.set() + finally: + sys.setswitchinterval(orig_si) + + self.assertEqual(item.cost, 2) + + def test_object_with_slots(self): + item = CachedCostItemWithSlots() + with self.assertRaisesRegex( + TypeError, + "No '__dict__' attribute on 'CachedCostItemWithSlots' instance to cache 'cost' property.", + ): + item.cost + + def test_immutable_dict(self): + class MyMeta(type): + @py_functools.cached_property + def prop(self): + return True + + class MyClass(metaclass=MyMeta): + pass + + with self.assertRaisesRegex( + TypeError, + "The '__dict__' attribute on 'MyMeta' instance does not support item assignment for caching 'prop' property.", + ): + MyClass.prop + + def test_reuse_different_names(self): + """Disallow this case because decorated function a would not be cached.""" + with self.assertRaises(RuntimeError) as ctx: + class ReusedCachedProperty: + @py_functools.cached_property + def a(self): + pass + + b = a + + self.assertEqual( + str(ctx.exception.__context__), + str(TypeError("Cannot assign the same cached_property to two different names ('a' and 'b').")) + ) + + def test_reuse_same_name(self): + """Reusing a cached_property on different classes under the same name is OK.""" + counter = 0 + + @py_functools.cached_property + def _cp(_self): + nonlocal counter + counter += 1 + return counter + + class A: + cp = _cp + + class B: + cp = _cp + + a = A() + b = B() + + self.assertEqual(a.cp, 1) + self.assertEqual(b.cp, 2) + self.assertEqual(a.cp, 1) + + def test_set_name_not_called(self): + cp = py_functools.cached_property(lambda s: None) + class Foo: + pass + + Foo.cp = cp + + with self.assertRaisesRegex( + TypeError, + "Cannot use cached_property instance without calling __set_name__ on it.", + ): + Foo().cp + + def test_access_from_class(self): + self.assertIsInstance(CachedCostItem.cost, py_functools.cached_property) + + def test_doc(self): + self.assertEqual(CachedCostItem.cost.__doc__, "The cost of the item.") + + if __name__ == '__main__': unittest.main() diff --git a/Misc/NEWS.d/next/Library/2018-05-18-22-52-34.bpo-21145.AiQMDx.rst b/Misc/NEWS.d/next/Library/2018-05-18-22-52-34.bpo-21145.AiQMDx.rst new file mode 100644 index 00000000000..a5973c33a30 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2018-05-18-22-52-34.bpo-21145.AiQMDx.rst @@ -0,0 +1,2 @@ +Add ``functools.cached_property`` decorator, for computed properties cached +for the life of the instance.