From a5b4b0d3371489328712bdbc99362c14ca14c502 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 18 May 2026 10:43:50 -0500 Subject: [PATCH 01/14] 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 7da59cba5170b35..3066dcfb1c7dab8 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 409b2c50478c40f..a1b660bb4d17715 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 c30386afe41849b..f3c50d9b8912457 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 000000000000000..bb13cb7b89fa90c --- /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 02/14] 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 a1b660bb4d17715..d49f3ff3f1e9620 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 f3c50d9b8912457..6da1b0a1f37201f 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 03/14] 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 3066dcfb1c7dab8..976e233de8990fc 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 d49f3ff3f1e9620..e46b4ede1d2e89b 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 30f839bcee1facdd8fa29605a784add5577041f4 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 18 May 2026 19:33:28 -0500 Subject: [PATCH 04/14] 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 | 30 ++++++++++-------------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 976e233de8990fc..bf95421e9b56057 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 e46b4ede1d2e89b..23cd43165b83922 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1217,8 +1217,6 @@ class _cached_method: """ 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() self._maxsize = maxsize self._typed = typed @@ -1242,27 +1240,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 9d109fa71df40e2193e65428e41eb3eeb8c0b678 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 18 May 2026 19:53:37 -0500 Subject: [PATCH 05/14] 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 bf95421e9b56057..b40bea55a7b5cb9 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. From d26358ae7f26a72b439fdb722383d1425b96849b Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 19 May 2026 16:31:32 -0500 Subject: [PATCH 06/14] Fix use of a latin abbreviation --- Doc/library/functools.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index b40bea55a7b5cb9..d5db1beb91e912b 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -134,7 +134,7 @@ The :mod:`!functools` module defines the following functions: 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. + an instance, for example, those which depend only on immutable attributes. Example:: From 0686932f85d883d92dae4c2dbd20fe948e1e9f32 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Thu, 28 May 2026 11:02:12 -0500 Subject: [PATCH 07/14] Update Doc/library/functools.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Edgar Ramírez Mondragón <16805946+edgarrmondragon@users.noreply.github.com> --- Doc/library/functools.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index d5db1beb91e912b..ceb16d5d319b712 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -157,7 +157,7 @@ The :mod:`!functools` module defines the following functions: descriptor protocol), so parallel access on a single instance can race to initialize. - This decorator requires that the each instance supports weak references. + This decorator requires that the 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. From 262a53e0883f2f433b1ccdb14859a67533fe8f6c Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Thu, 16 Jul 2026 20:58:36 -0500 Subject: [PATCH 08/14] Update functools to 'lazy import weakref' Rather than doing an old-style deferred import, with explanatory comments for why the deferred import style is used. Convert these to a top-level `lazy import weakref`. No explanatory comment is used, on the grounds that the new syntax is sufficiently low in cognitive cost that experienced maintainers will quickly recognize it and assume that the pattern is used to either break a cycle or reduce import costs. --- Lib/functools.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 23cd43165b83922..44d012f6be88905 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -16,12 +16,13 @@ from abc import get_cache_token from collections import namedtuple -# 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 from _thread import RLock +lazy import weakref + ################################################################################ ### update_wrapper() and wraps() decorator ################################################################################ @@ -907,11 +908,6 @@ def singledispatch(func): implementations can be registered using the register() attribute of the generic function. """ - # There are many programs that use functools without singledispatch, so we - # trade-off making singledispatch marginally slower for the benefit of - # making start-up of such applications slightly faster. - import weakref - registry = {} dispatch_cache = weakref.WeakKeyDictionary() cache_token = None @@ -1234,10 +1230,6 @@ 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(), defer use of weakref until/unless it - # is needed - import weakref - instance_id = id(instance) try: From fffc5d3f900815f3295299d539a3892b6116f311 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Thu, 16 Jul 2026 21:10:43 -0500 Subject: [PATCH 09/14] Add `functools.cached_method` to What's New --- Doc/whatsnew/3.16.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index cff0b8bbe32f0bd..675c07b6e7e58d9 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -86,10 +86,12 @@ New modules Improved modules ================ -module_name ------------ +functools +--------- -* TODO +* Added :func:`functools.cached_method` decorator for applying a cache to + instance methods. + (Contributed by Stephen Rosen in :gh:`150002`.) .. Add improved modules above alphabetically, not here at the end. From 06c2b189a4e2fb63983055fdfb59d22bc4b7e6b6 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 21 Jul 2026 13:55:57 -0500 Subject: [PATCH 10/14] Apply suggestions from code review Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Co-authored-by: Stan Ulbrych --- Doc/library/functools.rst | 4 ++-- Lib/functools.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index bbcaabc9cdef70c..46a1a8a31532b18 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -123,7 +123,7 @@ The :mod:`!functools` module defines the following functions: .. decorator:: cached_method(func) - cached_method(maxsize=128, typed=False) + cached_method(maxsize=None, typed=False) Decorator to wrap a method with a bounded or unbounded cache. @@ -164,7 +164,7 @@ The :mod:`!functools` module defines the following functions: *maxsize* and *typed* are supported as keyword arguments to the decorator, and are passed to the underlying :func:`lru_cache`. - .. versionadded:: 3.16 + .. versionadded:: next .. function:: cmp_to_key(func) diff --git a/Lib/functools.py b/Lib/functools.py index 55207c58730d9a4..1fb19dcd6edad62 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -20,7 +20,6 @@ from reprlib import recursive_repr from types import FunctionType, GenericAlias, MethodType, MappingProxyType, UnionType from _thread import RLock - lazy import weakref ################################################################################ @@ -1181,7 +1180,7 @@ def __get__(self, instance, owner=None): __class_getitem__ = classmethod(GenericAlias) ################################################################################ -### cached_method -- a version of lru_cache() which uses `id(self)` +### cached_method -- a version of lru_cache() which uses id(self) ################################################################################ From 365c4841600c9ab09df5b29619694c53ed34df7a Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 21 Jul 2026 14:04:50 -0500 Subject: [PATCH 11/14] Apply suggestion from @StanFromIreland Co-authored-by: Stan Ulbrych --- Doc/library/functools.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 46a1a8a31532b18..874145e1b65e17d 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -150,7 +150,7 @@ The :mod:`!functools` module defines the following functions: 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 + :func:`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 From 981013be2570ed51005b5c4b8785e17eece96d30 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 21 Jul 2026 14:06:14 -0500 Subject: [PATCH 12/14] Move `cached_method` above `cached_property` And add it to functools' `__all__` list. --- Doc/library/functools.rst | 90 ++++++++++++++++---------------- Lib/functools.py | 106 +++++++++++++++++++------------------- 2 files changed, 98 insertions(+), 98 deletions(-) diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 874145e1b65e17d..8fecb34f751882e 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -57,6 +57,51 @@ The :mod:`!functools` module defines the following functions: .. versionadded:: 3.9 +.. decorator:: cached_method(func) + cached_method(maxsize=None, 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`. + + :func:`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 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:: next + + .. decorator:: cached_property(func) Transform a method of a class into a property whose value is computed once @@ -122,51 +167,6 @@ The :mod:`!functools` module defines the following functions: Python 3.12+ this locking is removed. -.. decorator:: cached_method(func) - cached_method(maxsize=None, 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`. - - :func:`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 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:: next - - .. 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 1fb19dcd6edad62..651a385949a381e 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -12,7 +12,7 @@ __all__ = ['update_wrapper', 'wraps', 'WRAPPER_ASSIGNMENTS', 'WRAPPER_UPDATES', 'total_ordering', 'cache', 'cmp_to_key', 'lru_cache', 'reduce', 'partial', 'partialmethod', 'singledispatch', 'singledispatchmethod', - 'cached_property', 'Placeholder'] + 'cached_method', 'cached_property', 'Placeholder'] from abc import get_cache_token from collections import namedtuple @@ -1127,58 +1127,6 @@ def __wrapped__(self): def register(self): return self._unbound.register - -################################################################################ -### cached_property() - property result cached as instance attribute -################################################################################ - -_NOT_FOUND = object() - -class cached_property: - def __init__(self, func): - self.func = func - self.attrname = None - self.__doc__ = func.__doc__ - self.__module__ = func.__module__ - - 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=None): - 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: - 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 - - __class_getitem__ = classmethod(GenericAlias) - ################################################################################ ### cached_method -- a version of lru_cache() which uses id(self) ################################################################################ @@ -1255,3 +1203,55 @@ def decorator(func): return decorator else: return _cached_method(func, maxsize=maxsize, typed=typed) + + +################################################################################ +### cached_property() - property result cached as instance attribute +################################################################################ + +_NOT_FOUND = object() + +class cached_property: + def __init__(self, func): + self.func = func + self.attrname = None + self.__doc__ = func.__doc__ + self.__module__ = func.__module__ + + 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=None): + 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: + 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 + + __class_getitem__ = classmethod(GenericAlias) From 8086aa5443d5fb8a10f3603c9c1a2164d24f89a7 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 21 Jul 2026 14:12:37 -0500 Subject: [PATCH 13/14] Check type of 'maxsize' in cached_method --- Lib/functools.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/functools.py b/Lib/functools.py index 651a385949a381e..a96f5437147ce29 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1197,6 +1197,9 @@ def _get_or_create_cached_func(self, instance): def cached_method(func=None, /, maxsize=None, typed=False): + if maxsize is not None and not isinstance(maxsize, int) and not callable(maxsize): + raise TypeError( + 'Expected maxsize to be an integer, a callable, or None') if func is None: def decorator(func): return _cached_method(func, maxsize=maxsize, typed=typed) From a7f206cdaecaa49cb1abd54e943dbc3fb6210245 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 21 Jul 2026 14:14:50 -0500 Subject: [PATCH 14/14] Fix race condition in cached_method Multiple weakrefs can be created when there are concurrent writes to a "new cache". When they finalize, their attempts to clear the dict should be kept safe by using a pop() default. --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index a96f5437147ce29..e124f673ec23366 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -1134,7 +1134,7 @@ def register(self): def _cached_method_weakref_callback(cache_dict, id_key): def callback(ref): - cache_dict.pop(id_key) + cache_dict.pop(id_key, None) return callback