summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Doc/library/functools.rst33
-rw-r--r--Lib/functools.py55
-rw-r--r--Lib/test/test_functools.py166
-rw-r--r--Misc/NEWS.d/next/Library/2018-05-18-22-52-34.bpo-21145.AiQMDx.rst2
4 files changed, 256 insertions, 0 deletions
diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst
index 5e278f9..1b94f33 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 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()
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 0000000..a5973c3
--- /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.