diff --git a/docs/changes.rst b/docs/changes.rst index 40e6cc0a..d2e2a686 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -6,6 +6,11 @@ Version 2.5.2 **Bugs Fixed** +* Fixed ``wrapt.lru_cache`` retaining the class from the first call to a + cached class method and using it for calls through subclasses. The cache + remains shared, but now includes the bound class in its key, as + ``functools.lru_cache`` does. + * The special methods added to the class generated for an ``wrapt.AutoObjectProxy``, and adjusted when ``__wrapped__`` was reassigned, were determined by inspecting the wrapped object itself, diff --git a/src/wrapt/caching.py b/src/wrapt/caching.py index 4ef0e19b..f0cb6b00 100644 --- a/src/wrapt/caching.py +++ b/src/wrapt/caching.py @@ -40,16 +40,27 @@ def __call__(self, *args, **kwargs): # For class methods and static methods, use a single shared # cache stored on the parent wrapper. The cache is created - # from the bound __wrapped__ since the parent's __wrapped__ - # is the raw classmethod/staticmethod descriptor. + # from __wrapped__ since the parent's __wrapped__ is the raw + # classmethod/staticmethod descriptor. For class methods, cache + # the unbound function so the bound class can be a cache key. if not self._is_instance_method(): if parent._self_cache is None: with synchronized(parent): if parent._self_cache is None: + wrapped = self.__wrapped__ + if self._self_binding == "classmethod": + wrapped = wrapped.__func__ parent._self_cache = _functools_lru_cache( **parent._self_lru_kwargs - )(self.__wrapped__) + )(wrapped) + + # Keep the cache shared, but include the bound class in its key. + # Caching the bound method itself would retain the class of the + # first call and incorrectly use it for inherited calls too. + + if self._self_binding == "classmethod": + return parent._self_cache(self.__wrapped__.__self__, *args, **kwargs) return parent._self_cache(*args, **kwargs) diff --git a/tests/core/test_lru_cache.py b/tests/core/test_lru_cache.py index 065d6c05..f92a8030 100644 --- a/tests/core/test_lru_cache.py +++ b/tests/core/test_lru_cache.py @@ -244,6 +244,37 @@ def test_maxsize_passed_through(self): self.assertEqual(info.maxsize, 32) +class TestInheritedClassMethod(unittest.TestCase): + def test_preserves_bound_class_and_shared_cache(self): + for child_first in (False, True): + with self.subTest(child_first=child_first): + calls = [] + + class Base: + @wrapt.lru_cache(maxsize=32) + @classmethod + def method(cls, value): + calls.append((cls, value)) + return cls, value + + class Child(Base): + pass + + first, second = (Child, Base) if child_first else (Base, Child) + self.assertEqual(first.method(1), (first, 1)) + self.assertEqual(second.method(1), (second, 1)) + self.assertEqual(second.method(2), (second, 2)) + self.assertEqual(first().method(1), (first, 1)) + self.assertEqual(second().method(1), (second, 1)) + self.assertEqual(calls, [(first, 1), (second, 1), (second, 2)]) + info = Base.method.cache_info() + self.assertEqual(info, Child.method.cache_info()) + self.assertEqual((info.hits, info.misses, info.currsize), (2, 3, 3)) + self.assertEqual(info.maxsize, 32) + Child.method.cache_clear() + self.assertEqual(Base.method.cache_info().currsize, 0) + + class TestStaticMethod(unittest.TestCase): def setUp(self): CachedClass.static_method.cache_clear()