From a5b4b0d3371489328712bdbc99362c14ca14c502 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 18 May 2026 10:43:50 -0500 Subject: [PATCH 1/5] Introduce a `functools.cached_method` decorator resolves #102618 This definition of `cached_method` is based on the discussion on DPO: https://discuss.python.org/t/107164 Some tradeoffs need to be made in any version of such a decorator. In this particular implementation, the choices made are as follows: - lru_cache will be used under the hood, and `cache_clear()` and `cache_info()` will be exposed - caches will be stored in a separate dict, indexed by `id(self)` -- meaning instances do not need to be hashable and will not share caches - weakrefs will be used to delete entries from the cache, so the instances must be weak-referencable - lru_cache itself is not threadsafe, but initialization of the caches is threadsafe -- this avoids confusing scenarios in which cache entries "disappear" New documentation is included, marked for 3.16, and a small number of new tests are added. --- Doc/library/functools.rst | 43 +++++++++ Lib/functools.py | 88 ++++++++++++++++++- Lib/test/test_functools.py | 50 +++++++++++ ...-05-18-11-34-16.gh-issue-102618.4y-SEm.rst | 2 + 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-05-18-11-34-16.gh-issue-102618.4y-SEm.rst diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 7da59cba5170b3..3066dcfb1c7dab 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -122,6 +122,49 @@ The :mod:`!functools` module defines the following functions: Python 3.12+ this locking is removed. +.. decorator:: cached_method(func) + + Decorator to wrap a method with a bounded or unbounded cache. + + When :func:`cache` or :func:`lru_cache` are used on an instance method, the + instance (``self``) will be stored in the cache. As a result, instances cannot be + garbage collected until the relevant caches are cleared. + This decorator uses :func:`lru_cache`, but it wraps the unbound method to accept + a weakref and ensures that caches are cleared when instances are garbage collected. + + This is useful for expensive computations which are consistent with respect to + an instance, e.g., those which depend only on immutable attributes. + + Example:: + + class DataSet: + + def __init__(self, sequence_of_ints): + self._data = tuple(sequence_of_ints) + + @cached_method + def shifted(self, shift): + return DataSet([value + shift for value in self._data]) + + On instances, :func:`cached_method` behaves very similarly to :func:`cache`, + providing :func:`cache_info` and :func:`cache_clear`. + + The *cached_method* does not prevent all possible race conditions in + multi-threaded usage. The function could run more than once on the + same instance, with the same inputs, with the latest run setting the cached + value. However, initialization of the cached method, which happens lazily on + first access, is itself threadsafe. + + This decorator requires that the each instance supports weak references. + Some immutable types and slotted classes without ``__weakref__`` as one of + the defined slots will encounter errors when the cached method is first used. + + *maxsize* and *typed* are supported as keyword arguments to the decorator, + and are passed to the underlying :func:`lru_cache`. + + .. versionadded:: 3.16 + + .. 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 409b2c50478c40..a1b660bb4d1771 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -16,7 +16,7 @@ from abc import get_cache_token from collections import namedtuple -# import weakref # Deferred to single_dispatch() +# import weakref # Deferred to single_dispatch() and cached_method() from operator import itemgetter from reprlib import recursive_repr from types import FunctionType, GenericAlias, MethodType, MappingProxyType, UnionType @@ -1183,3 +1183,89 @@ def __get__(self, instance, owner=None): return val __class_getitem__ = classmethod(GenericAlias) + +################################################################################ +### cached_method -- a version of lru_cache() which uses `id(self)` +################################################################################ + + +def _cached_method_weakref_callback(cache_dict, id_key): + def callback(ref): + cache_dict.pop(id_key) + return callback + + +def _wrap_unbound_cached_method(ref, unbound_method, maxsize, typed): + @lru_cache(maxsize, typed) + def wrapped(*args, **kwargs): + return unbound_method(ref(), *args, **kwargs) + return wrapped + + +class cached_method: + """ + A caching decorator for use on instance methods. + + Using cache or lru_cache on methods is problematic because the instance is put into + the cache and cannot be garbage collected until the cache is cleared. This decorator + uses a cache based on `id(self)` and a weakref to clear cache entries. + + The instance must be weak-referencable. + + By default, this provides an infinite sized cache similar to functools.cache. Use + *maxsize* and *typed* to set these attributes of the underlying LRU cache. + """ + def __init__(self, func=None, /, maxsize=None, typed=False): + self.func = None + self._maxsize = maxsize + self._typed = typed + self._function_table = {} + # we need a lock when initializing per-instance caches + self._cache_init_lock = RLock() + + if func is not None: + self.func = func + update_wrapper(self, func) + + def __call__(self, func): + if self.func is not None: + raise TypeError( + "Each cached_method decorator can only apply to one function." + ) + self.func = func + update_wrapper(self, func) + return self + + def __get__(self, instance, owner=None): + # similar to singledispatch(), we want to defer use of weakref until/unless it + # is needed + import weakref + + if instance is None: + return self + + instance_id = id(instance) + + # first try to retrieve the cached func without locking (thus avoiding any + # unnecessary contention when there is a value), but then retry + # under a lock to actually provide safety such that two parallel threads won't + # construct distinct caches simultaneously + try: + ref, cached_func = self._function_table[instance_id] + except KeyError: + with self._cache_init_lock: + try: + ref, cached_func = self._function_table[instance_id] + except KeyError: + ref = weakref.ref( + instance, + _cached_method_weakref_callback( + self._function_table, instance_id + ), + ) + cached_func = _wrap_unbound_cached_method( + ref, self.func, self._maxsize, self._typed + ) + self._function_table[instance_id] = ref, cached_func + + return cached_func diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index c30386afe41849..f3c50d9b891245 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3810,5 +3810,55 @@ def prop(self): self.assertEqual(t.prop, 1) +class CachedValueAdder: + def __init__(self, value): + self.value = value + + @py_functools.cached_method + def add(self, other): + return self.value + other + + +class TestCachedMethod(unittest.TestCase): + module = py_functools + + def test_cached_usage(self): + one = CachedValueAdder(1) + self.assertEqual(one.add(2), 3) + one.value = 2 + self.assertEqual(one.add(2), 3) # still 3, not 4 + one.add.cache_clear() + self.assertEqual(one.add(2), 4) # now 4 + + def test_cache_info(self): + one = CachedValueAdder(1) + self.assertEqual(one.add.cache_info(), + self.module._CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)) + for _ in range(3): + for i in range(10): + one.add(i) + self.assertEqual(one.add.cache_info(), + self.module._CacheInfo(hits=20, misses=10, maxsize=None, currsize=10)) + one.add.cache_clear() + self.assertEqual(one.add.cache_info(), + self.module._CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)) + + def test_reapplication_causes_type_error(self): + with self.assertRaisesRegex( + TypeError, + r"Each cached_method decorator can only apply to one function\.", + ): + decorator = py_functools.cached_method() + + class MyObject: + @decorator + def a(self): + return None + + @decorator + def b(self): + return None + + if __name__ == '__main__': unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-05-18-11-34-16.gh-issue-102618.4y-SEm.rst b/Misc/NEWS.d/next/Library/2026-05-18-11-34-16.gh-issue-102618.4y-SEm.rst new file mode 100644 index 00000000000000..bb13cb7b89fa90 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-18-11-34-16.gh-issue-102618.4y-SEm.rst @@ -0,0 +1,2 @@ +Added a new ``functools.cached_method`` decorator, which uses weakrefs and +avoids putting ``self`` into the cache. From 142581912e60fc4e92716ca8e35f1129497d0e0d Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 18 May 2026 16:51:05 -0500 Subject: [PATCH 2/5] Refine the implementation of cached_method Thanks to feedback from @jaraco, this adjustment makes the public interface for `cached_method` a function which is responsible for the argument management, rather than trying to use `__call__` on the descriptor itself. This frees up `__call__` to lookup the relevant LRU-cached function and invoke it, which makes the `_cached_method` descriptor suitable for use as a `property.fget` callable. Tests are updated to indicate that a decorator can be prepared and then used repeatedly (which was previously an explicit error), and can be used under a property decorator. --- Lib/functools.py | 39 +++++++++++++++++++++----------------- Lib/test/test_functools.py | 36 ++++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index a1b660bb4d1771..d49f3ff3f1e962 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1202,7 +1202,7 @@ def wrapped(*args, **kwargs): return wrapped -class cached_method: +class _cached_method: """ A caching decorator for use on instance methods. @@ -1215,35 +1215,31 @@ class cached_method: By default, this provides an infinite sized cache similar to functools.cache. Use *maxsize* and *typed* to set these attributes of the underlying LRU cache. """ - def __init__(self, func=None, /, maxsize=None, typed=False): - self.func = None - self._maxsize = maxsize - self._typed = typed + def __init__(self, func, /, maxsize=None, typed=False): self._function_table = {} # we need a lock when initializing per-instance caches self._cache_init_lock = RLock() - if func is not None: - self.func = func - update_wrapper(self, func) + self._maxsize = maxsize + self._typed = typed - def __call__(self, func): - if self.func is not None: - raise TypeError( - "Each cached_method decorator can only apply to one function." - ) self.func = func update_wrapper(self, func) - return self + + def __call__(self, instance, *args, **kwargs): + cached_func = self._get_or_create_cached_func(instance) + return cached_func(*args, **kwargs) def __get__(self, instance, owner=None): + if instance is None: + return self + return self._get_or_create_cached_func(instance) + + def _get_or_create_cached_func(self, instance): # similar to singledispatch(), we want to defer use of weakref until/unless it # is needed import weakref - if instance is None: - return self - instance_id = id(instance) # first try to retrieve the cached func without locking (thus avoiding any @@ -1269,3 +1265,12 @@ def __get__(self, instance, owner=None): self._function_table[instance_id] = ref, cached_func return cached_func + + +def cached_method(func=None, /, maxsize=None, typed=False): + if func is None: + def decorator(func): + return _cached_method(func, maxsize=maxsize, typed=typed) + return decorator + else: + return _cached_method(func, maxsize=maxsize, typed=typed) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index f3c50d9b891245..6da1b0a1f37201 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3843,21 +3843,31 @@ def test_cache_info(self): self.assertEqual(one.add.cache_info(), self.module._CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)) - def test_reapplication_causes_type_error(self): - with self.assertRaisesRegex( - TypeError, - r"Each cached_method decorator can only apply to one function\.", - ): - decorator = py_functools.cached_method() + def test_reapplication_is_allowed(self): + decorator = py_functools.cached_method(maxsize=10) - class MyObject: - @decorator - def a(self): - return None + class MyObject: + @decorator + def a(self): + return 1 + + @decorator + def b(self): + return 2 + + x = MyObject() + self.assertEqual(x.a(), 1) + self.assertEqual(x.b(), 2) + + def test_cached_method_under_property(self): + class MyObject: + @property + @py_functools.cached_method + def foo(self): + return 1 - @decorator - def b(self): - return None + x = MyObject() + self.assertEqual(x.foo, 1) if __name__ == '__main__': From 8ff6b31ebf8a8581c3cce496d926ba86c2d008f8 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 18 May 2026 16:57:46 -0500 Subject: [PATCH 3/5] Minor tweaks to cached_method docs - Fix doc references - Use imperative mood in a comment Co-authored-by: Jason R. Coombs <308610+jaraco@users.noreply.github.com> --- Doc/library/functools.rst | 2 +- Lib/functools.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 3066dcfb1c7dab..976e233de8990f 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -147,7 +147,7 @@ The :mod:`!functools` module defines the following functions: return DataSet([value + shift for value in self._data]) On instances, :func:`cached_method` behaves very similarly to :func:`cache`, - providing :func:`cache_info` and :func:`cache_clear`. + providing :func:`!cache_info` and :func:`!cache_clear`. The *cached_method* does not prevent all possible race conditions in multi-threaded usage. The function could run more than once on the diff --git a/Lib/functools.py b/Lib/functools.py index d49f3ff3f1e962..e46b4ede1d2e89 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1236,7 +1236,7 @@ def __get__(self, instance, owner=None): return self._get_or_create_cached_func(instance) def _get_or_create_cached_func(self, instance): - # similar to singledispatch(), we want to defer use of weakref until/unless it + # similar to singledispatch(), defer use of weakref until/unless it # is needed import weakref From e66701e66997f0e23196e0f8175883418b973fc8 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 18 May 2026 19:33:28 -0500 Subject: [PATCH 4/5] Remove cached_method locking logic Although this logic tried to avoid lock contention, there were still scenarios under which it was possible and could negatively impact users. Removal and adjustment of the documentation puts responsibility for locking/safety onto users, similar to other parts of the stdlib. --- Doc/library/functools.rst | 5 +++-- Lib/functools.py | 28 ++++++++++------------------ 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 976e233de8990f..bf95421e9b5605 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -152,8 +152,9 @@ The :mod:`!functools` module defines the following functions: The *cached_method* does not prevent all possible race conditions in multi-threaded usage. The function could run more than once on the same instance, with the same inputs, with the latest run setting the cached - value. However, initialization of the cached method, which happens lazily on - first access, is itself threadsafe. + value. The per-instance cache is lazily initialized on first access (via the + descriptor protocol), so parallel access on a single instance can race to + initialize. This decorator requires that the each instance supports weak references. Some immutable types and slotted classes without ``__weakref__`` as one of diff --git a/Lib/functools.py b/Lib/functools.py index e46b4ede1d2e89..be5ffa648b4671 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1242,27 +1242,19 @@ def _get_or_create_cached_func(self, instance): instance_id = id(instance) - # first try to retrieve the cached func without locking (thus avoiding any - # unnecessary contention when there is a value), but then retry - # under a lock to actually provide safety such that two parallel threads won't - # construct distinct caches simultaneously try: ref, cached_func = self._function_table[instance_id] except KeyError: - with self._cache_init_lock: - try: - ref, cached_func = self._function_table[instance_id] - except KeyError: - ref = weakref.ref( - instance, - _cached_method_weakref_callback( - self._function_table, instance_id - ), - ) - cached_func = _wrap_unbound_cached_method( - ref, self.func, self._maxsize, self._typed - ) - self._function_table[instance_id] = ref, cached_func + ref = weakref.ref( + instance, + _cached_method_weakref_callback( + self._function_table, instance_id + ), + ) + cached_func = _wrap_unbound_cached_method( + ref, self.func, self._maxsize, self._typed + ) + self._function_table[instance_id] = ref, cached_func return cached_func From 75c85ce910249c359d3c94310b598715bd2b6541 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 18 May 2026 19:53:37 -0500 Subject: [PATCH 5/5] Fix signature line in docs for cached_method --- Doc/library/functools.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index bf95421e9b5605..b40bea55a7b5cb 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -123,6 +123,7 @@ The :mod:`!functools` module defines the following functions: .. decorator:: cached_method(func) + cached_method(maxsize=128, typed=False) Decorator to wrap a method with a bounded or unbounded cache.