Repository navigation
bpo-21145: Add cached_property decorator in functools #6982
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
1cda951
72259cd
26f3f43
567e819
f7876d0
354a975
f006f68
d6bed4b
b41380d
b504a20
977ece0
5bdeaab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -868,3 +868,50 @@ 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: # objects with __slots__ have no __dict__ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tweak the comment here along similar lines to the note in the docs |
||
| 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 = cache[self.attrname] = self.func(instance) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to the |
||
| return val | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2313,5 +2313,156 @@ 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this needed? It seems to be always shadowed in |
||
|
|
||
| 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_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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| Add ``functools.cached_property`` decorator, for computed properties cached | ||
| for the life of the instance. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd word this note slightly differently, as there are multiple ways to end up with instances that don't have a mutable mapping as their
__dict__attribute, and you can also include__dict__in a__slots__list if you want the fast C level access for selected fields, but aren't worried about the per-instance dict memory overhead.Something like: