diff options
author | Carl Meyer <carl@oddbird.net> | 2018-08-28 07:11:56 (GMT) |
---|---|---|
committer | Nick Coghlan <ncoghlan@gmail.com> | 2018-08-28 07:11:56 (GMT) |
commit | d658deac6060ee92b449a3bf424b460eafd99f3e (patch) | |
tree | b20a3a288b17dd17fbd5e555f139b2c1df80bcb7 /Lib | |
parent | 216b745eafa7cd4a683a8405dcfbd7f5567f504c (diff) | |
download | cpython-d658deac6060ee92b449a3bf424b460eafd99f3e.zip cpython-d658deac6060ee92b449a3bf424b460eafd99f3e.tar.gz cpython-d658deac6060ee92b449a3bf424b460eafd99f3e.tar.bz2 |
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.
Diffstat (limited to 'Lib')
-rw-r--r-- | Lib/functools.py | 55 | ||||
-rw-r--r-- | Lib/test/test_functools.py | 166 |
2 files changed, 221 insertions, 0 deletions
diff --git a/Lib/functools.py b/Lib/functools.py index b3428a4..51048f5 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -868,3 +868,58 @@ class singledispatchmethod: @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 10bf0a2..200a5eb 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2313,5 +2313,171 @@ class TestSingleDispatch(unittest.TestCase): 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() |