diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 7da59cba5170b3..d5db1beb91e912 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -122,6 +122,51 @@ The :mod:`!functools` module defines the following functions: Python 3.12+ this locking is removed. +.. decorator:: cached_method(func) + cached_method(maxsize=128, typed=False) + + 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, for example, 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. 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 + 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..23cd43165b8392 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,84 @@ 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, /, maxsize=None, typed=False): + self._function_table = {} + + self._maxsize = maxsize + self._typed = typed + + self.func = func + update_wrapper(self, func) + + 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(), defer use of weakref until/unless it + # is needed + import weakref + + instance_id = id(instance) + + 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 + + +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 c30386afe41849..6da1b0a1f37201 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3810,5 +3810,65 @@ 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_is_allowed(self): + decorator = py_functools.cached_method(maxsize=10) + + 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 + + x = MyObject() + self.assertEqual(x.foo, 1) + + 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.