Skip to content

gh-102618: Introduce a functools.cached_method decorator - #150002

Open
sirosen wants to merge 18 commits into
python:mainfrom
sirosen:cached-method
Open

sirosen wants to merge 18 commits into
python:mainfrom
sirosen:cached-method

Conversation

@sirosen

@sirosen sirosen commented May 18, 2026 •

Copy link
Copy Markdown
Contributor

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"
    EDIT: in response to review feedback, this locking was removed; users can lock if they want thread safety

New documentation is included, marked for 3.16, and a small number of new
tests are added.


In addition to @rhettinger's review, as the listed owner for functools, @sobolevn expressed interest in reviewing changes.
@kenahoo and @stevendaprano may also want a look based on their prior interest.

Everyone I've spoken with about this change1 has been supportive of the core idea. In particular @jaraco noted that he would support this addition, and maintains a very different implementation. There are many other ways of writing this, with different tradeoffs. Perhaps the most notable class of those are versions which store caches on the instance itself, like this one suggested here in the DPO discussion -- this writes the cached method into __dict__, similarly to cached_property.

Of course, I'm happy to alter or re-approach any part of this per review! 😄

Footnotes

  1. at PyCon US 2026 ↩

@read-the-docs-community

read-the-docs-community Bot commented May 18, 2026 •

Copy link
Copy Markdown

resolves python#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.
Comment thread Lib/functools.py Resolved
Comment thread Lib/functools.py Resolved
sirosen and others added 2 commits May 18, 2026 16:51
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.
- Fix doc references
- Use imperative mood in a comment

Co-authored-by: Jason R. Coombs <[email protected]>
@sirosen

sirosen commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

Jelle pointed out to me that although I have tried to be careful about the locking I included, to minimize contention, the lock I defined is per-cached_method, meaning it is defined per-class. With many instances of the same class in parallel threads, users would potentially see lock contention.
My initial instinct on this was to look for a way to make the locking per-instance, but having been referred to this DPO thread which addressed the same topic for cached_property, there's a strong case for just removing the lock.

I'll push a change to remove the locking and clarify the thread-safety documentation.

@sirosen

sirosen commented May 19, 2026

Copy link
Copy Markdown
Contributor Author

@willingc , this is the PR that I mentioned to you; you said you wanted a ping to take a look. 🙂

Comment thread Lib/functools.py Resolved
Comment thread Lib/functools.py Resolved
sirosen added 2 commits May 19, 2026 11:02
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.
Comment thread Doc/library/functools.rst Resolved
@GrahamDumpleton

GrahamDumpleton commented May 24, 2026 •

Copy link
Copy Markdown

FWIW. The wrapt package has a lru_cache helper which still uses functools.lru_cache internally, but which uses wrapt to provide a single decorator which will work on any of functions, instance methods, class methods etc. This was released in wrapt 2.0.0, but had been added well before even knew about this PR:

Comment thread Doc/library/functools.rst Resolved
Co-authored-by: Edgar Ramírez Mondragón <[email protected]>

@jaraco jaraco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, this implementation is fairly clean and simple. The documentation shows pretty clearly what the user's expectations should be. The tradeoffs chosen have been well considered and a reasonable path elected. I propose to get this merged and give it some exposure through the beta releases. From there iterate or pivot.

@StanFromIreland StanFromIreland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a What's New in 3.16 entry.

sirosen added 2 commits July 16, 2026 20:58
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.
@sirosen
sirosen requested a review from AA-Turner as a code owner July 17, 2026 02:11
@sirosen

sirosen commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor Author

I've applied the lazy import change. My commit message explains why I did not preserve any comment about it, but I'm open to feedback on that.

I also added the What's New entry.
Should I go ahead and merge main back into this branch? (I don't want to do that prematurely; maybe I should leave it for the final approval+merge?) (Confirmed quickly with someone that I should.)

@hugovk hugovk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have docs tooling that will update next to whatever the next pre-release is, for example:

"Added in version 3.16.0a0 (unreleased)"

Comment thread Doc/library/functools.rst Resolved
Comment thread Doc/library/functools.rst Resolved
Comment thread Doc/library/functools.rst Resolved
Comment thread Doc/whatsnew/3.16.rst Resolved
Comment thread Lib/functools.py Resolved
Comment thread Lib/functools.py Resolved
Comment thread Lib/functools.py Resolved
Comment thread Lib/functools.py Resolved
Comment thread Doc/library/functools.rst Resolved
Comment thread Lib/functools.py Resolved
sirosen and others added 7 commits July 21, 2026 13:55
Co-authored-by: Hugo van Kemenade <[email protected]>
Co-authored-by: Stan Ulbrych <[email protected]>
And add it to functools' `__all__` list.
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.
In order for `help(functools.cached_method)` to show the docstring, it
needs to be attached to the public interface, not the implementation.

This docstring was "misplaced" in the course of refactors to this code
during review.
Co-authored-by: Stan Ulbrych <[email protected]>
@sirosen

sirosen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

I just saw #154069 . If that goes in, this should get updated to also use setdefault when initializing the lookup table.
If that merges first, I intend to update this PR.

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

This PR is stale because it has been open for 90 days with no activity.

@github-actions github-actions Bot added the stale Stale PR or inactive for long period of time. label Nov 5, 2026
@rhettinger

Copy link
Copy Markdown
Contributor

The docs should specify what happens in code like this:

@dataclass
class Equity:
    str: symbol

    @cached_method(maxsize=16)
    def opening_price(self, date):
        return expensive_query(self.symbol, date)

e1 = Equity('GOOG')
print(e1.opening_price, date(2020, 2, 20))  # Miss  ? Unhashable instances allowed
print(e1.opening_price, date(2020, 2, 20))  # Hit
print(e1.opening_price, date(2020, 2, 25))  # Miss

e2 = Equity('GOOG')
print(e2.opening_price, date(2020, 2, 20))  # ? Cross-instance caching

e1.symbol = 'ALPB'
print(e1.opening_price, date(2020, 2, 20))  # ? Detect attribute change

del e1                                      # ? Immediate clear

Comment thread Doc/library/functools.rst Outdated

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or until the entry ages out of the lru (pretty much like any other entry).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! I tried to improve this in a subtle way, by correcting "relevant caches are cleared" to "relevant cache entries are cleared".

We can adjust it more -- my goal is to be both precise and concise. 😁

- Note "cache entries" rather than "caches", thereby capturing that
  eviction from an LRU cache covers the GC issue for cache/lru_cache

- Add a note on hashability, next to the note about weakref. This
  provides a forum for explaining that `id()` is used and what the
  implications of that choice are.
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Nov 7, 2026
@sirosen

sirosen commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I added a doc note about hashability, which explains that

  • instances don't need to be hashable
  • distinct instances get distinct caches, even if they hash equal

I'm not sure what, if anything, to add about two of these noted cases:

  1. attribute change

    e1.symbol = 'ALPB'
    print(e1.opening_price, date(2020, 2, 20))  # ? Detect attribute change

    The answer is that there is no detection. I think this might be surprising if someone is expecting the implementation to use hash() rather than id(). Does it need some special call-out now that the use of id() is documented?

  2. cleanup timing

    del e1                                      # ? Immediate clear

    It is documented as being tied to weakref, so the immediacy of the cache-clear is based on the immediacy of GC when del is called. I think this is considered an implementation detail of CPython? But it is in practice immediate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting merge stale Stale PR or inactive for long period of time. type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a functools.cache variant for methods to avoid keeping instances alive

8 participants