Skip to content

Commit d658dea

Browse files
carljmncoghlan
authored andcommitted
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.
1 parent 216b745 commit d658dea

File tree

4 files changed

+256
-0
lines changed

4 files changed

+256
-0
lines changed

Doc/library/functools.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,39 @@ function for the purposes of this module.
2020

2121
The :mod:`functools` module defines the following functions:
2222

23+
.. decorator:: cached_property(func)
24+
25+
Transform a method of a class into a property whose value is computed once
26+
and then cached as a normal attribute for the life of the instance. Similar
27+
to :func:`property`, with the addition of caching. Useful for expensive
28+
computed properties of instances that are otherwise effectively immutable.
29+
30+
Example::
31+
32+
class DataSet:
33+
def __init__(self, sequence_of_numbers):
34+
self._data = sequence_of_numbers
35+
36+
@cached_property
37+
def stdev(self):
38+
return statistics.stdev(self._data)
39+
40+
@cached_property
41+
def variance(self):
42+
return statistics.variance(self._data)
43+
44+
.. versionadded:: 3.8
45+
46+
.. note::
47+
48+
This decorator requires that the ``__dict__`` attribute on each instance
49+
be a mutable mapping. This means it will not work with some types, such as
50+
metaclasses (since the ``__dict__`` attributes on type instances are
51+
read-only proxies for the class namespace), and those that specify
52+
``__slots__`` without including ``__dict__`` as one of the defined slots
53+
(as such classes don't provide a ``__dict__`` attribute at all).
54+
55+
2356
.. function:: cmp_to_key(func)
2457

2558
Transform an old-style comparison function to a :term:`key function`. Used

Lib/functools.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,3 +868,58 @@ def _method(*args, **kwargs):
868868
@property
869869
def __isabstractmethod__(self):
870870
return getattr(self.func, '__isabstractmethod__', False)
871+
872+
873+
################################################################################
874+
### cached_property() - computed once per instance, cached as attribute
875+
################################################################################
876+
877+
_NOT_FOUND = object()
878+
879+
880+
class cached_property:
881+
def __init__(self, func):
882+
self.func = func
883+
self.attrname = None
884+
self.__doc__ = func.__doc__
885+
self.lock = RLock()
886+
887+
def __set_name__(self, owner, name):
888+
if self.attrname is None:
889+
self.attrname = name
890+
elif name != self.attrname:
891+
raise TypeError(
892+
"Cannot assign the same cached_property to two different names "
893+
f"({self.attrname!r} and {name!r})."
894+
)
895+
896+
def __get__(self, instance, owner):
897+
if instance is None:
898+
return self
899+
if self.attrname is None:
900+
raise TypeError(
901+
"Cannot use cached_property instance without calling __set_name__ on it.")
902+
try:
903+
cache = instance.__dict__
904+
except AttributeError: # not all objects have __dict__ (e.g. class defines slots)
905+
msg = (
906+
f"No '__dict__' attribute on {type(instance).__name__!r} "
907+
f"instance to cache {self.attrname!r} property."
908+
)
909+
raise TypeError(msg) from None
910+
val = cache.get(self.attrname, _NOT_FOUND)
911+
if val is _NOT_FOUND:
912+
with self.lock:
913+
# check if another thread filled cache while we awaited lock
914+
val = cache.get(self.attrname, _NOT_FOUND)
915+
if val is _NOT_FOUND:
916+
val = self.func(instance)
917+
try:
918+
cache[self.attrname] = val
919+
except TypeError:
920+
msg = (
921+
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
922+
f"does not support item assignment for caching {self.attrname!r} property."
923+
)
924+
raise TypeError(msg) from None
925+
return val

Lib/test/test_functools.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2313,5 +2313,171 @@ def f(*args):
23132313
with self.assertRaisesRegex(TypeError, msg):
23142314
f()
23152315

2316+
2317+
class CachedCostItem:
2318+
_cost = 1
2319+
2320+
def __init__(self):
2321+
self.lock = py_functools.RLock()
2322+
2323+
@py_functools.cached_property
2324+
def cost(self):
2325+
"""The cost of the item."""
2326+
with self.lock:
2327+
self._cost += 1
2328+
return self._cost
2329+
2330+
2331+
class OptionallyCachedCostItem:
2332+
_cost = 1
2333+
2334+
def get_cost(self):
2335+
"""The cost of the item."""
2336+
self._cost += 1
2337+
return self._cost
2338+
2339+
cached_cost = py_functools.cached_property(get_cost)
2340+
2341+
2342+
class CachedCostItemWait:
2343+
2344+
def __init__(self, event):
2345+
self._cost = 1
2346+
self.lock = py_functools.RLock()
2347+
self.event = event
2348+
2349+
@py_functools.cached_property
2350+
def cost(self):
2351+
self.event.wait(1)
2352+
with self.lock:
2353+
self._cost += 1
2354+
return self._cost
2355+
2356+
2357+
class CachedCostItemWithSlots:
2358+
__slots__ = ('_cost')
2359+
2360+
def __init__(self):
2361+
self._cost = 1
2362+
2363+
@py_functools.cached_property
2364+
def cost(self):
2365+
raise RuntimeError('never called, slots not supported')
2366+
2367+
2368+
class TestCachedProperty(unittest.TestCase):
2369+
def test_cached(self):
2370+
item = CachedCostItem()
2371+
self.assertEqual(item.cost, 2)
2372+
self.assertEqual(item.cost, 2) # not 3
2373+
2374+
def test_cached_attribute_name_differs_from_func_name(self):
2375+
item = OptionallyCachedCostItem()
2376+
self.assertEqual(item.get_cost(), 2)
2377+
self.assertEqual(item.cached_cost, 3)
2378+
self.assertEqual(item.get_cost(), 4)
2379+
self.assertEqual(item.cached_cost, 3)
2380+
2381+
def test_threaded(self):
2382+
go = threading.Event()
2383+
item = CachedCostItemWait(go)
2384+
2385+
num_threads = 3
2386+
2387+
orig_si = sys.getswitchinterval()
2388+
sys.setswitchinterval(1e-6)
2389+
try:
2390+
threads = [
2391+
threading.Thread(target=lambda: item.cost)
2392+
for k in range(num_threads)
2393+
]
2394+
with support.start_threads(threads):
2395+
go.set()
2396+
finally:
2397+
sys.setswitchinterval(orig_si)
2398+
2399+
self.assertEqual(item.cost, 2)
2400+
2401+
def test_object_with_slots(self):
2402+
item = CachedCostItemWithSlots()
2403+
with self.assertRaisesRegex(
2404+
TypeError,
2405+
"No '__dict__' attribute on 'CachedCostItemWithSlots' instance to cache 'cost' property.",
2406+
):
2407+
item.cost
2408+
2409+
def test_immutable_dict(self):
2410+
class MyMeta(type):
2411+
@py_functools.cached_property
2412+
def prop(self):
2413+
return True
2414+
2415+
class MyClass(metaclass=MyMeta):
2416+
pass
2417+
2418+
with self.assertRaisesRegex(
2419+
TypeError,
2420+
"The '__dict__' attribute on 'MyMeta' instance does not support item assignment for caching 'prop' property.",
2421+
):
2422+
MyClass.prop
2423+
2424+
def test_reuse_different_names(self):
2425+
"""Disallow this case because decorated function a would not be cached."""
2426+
with self.assertRaises(RuntimeError) as ctx:
2427+
class ReusedCachedProperty:
2428+
@py_functools.cached_property
2429+
def a(self):
2430+
pass
2431+
2432+
b = a
2433+
2434+
self.assertEqual(
2435+
str(ctx.exception.__context__),
2436+
str(TypeError("Cannot assign the same cached_property to two different names ('a' and 'b')."))
2437+
)
2438+
2439+
def test_reuse_same_name(self):
2440+
"""Reusing a cached_property on different classes under the same name is OK."""
2441+
counter = 0
2442+
2443+
@py_functools.cached_property
2444+
def _cp(_self):
2445+
nonlocal counter
2446+
counter += 1
2447+
return counter
2448+
2449+
class A:
2450+
cp = _cp
2451+
2452+
class B:
2453+
cp = _cp
2454+
2455+
a = A()
2456+
b = B()
2457+
2458+
self.assertEqual(a.cp, 1)
2459+
self.assertEqual(b.cp, 2)
2460+
self.assertEqual(a.cp, 1)
2461+
2462+
def test_set_name_not_called(self):
2463+
cp = py_functools.cached_property(lambda s: None)
2464+
class Foo:
2465+
pass
2466+
2467+
Foo.cp = cp
2468+
2469+
with self.assertRaisesRegex(
2470+
TypeError,
2471+
"Cannot use cached_property instance without calling __set_name__ on it.",
2472+
):
2473+
Foo().cp
2474+
2475+
def test_access_from_class(self):
2476+
self.assertIsInstance(CachedCostItem.cost, py_functools.cached_property)
2477+
2478+
def test_doc(self):
2479+
self.assertEqual(CachedCostItem.cost.__doc__, "The cost of the item.")
2480+
2481+
23162482
if __name__ == '__main__':
23172483
unittest.main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add ``functools.cached_property`` decorator, for computed properties cached
2+
for the life of the instance.

0 commit comments

Comments
 (0)