Skip to content

Commit 0ae09fd

Browse files
Merge branch 'release/2.2.2'
2 parents 787db02 + 86db7a9 commit 0ae09fd

6 files changed

Lines changed: 265 additions & 4 deletions

File tree

docs/bundled.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,35 @@ single shared cache.
112112
>>> fibonacci.cache_parameters()
113113
{'maxsize': 128, 'typed': False}
114114

115+
Because the per-instance cache is stored as an attribute on the instance, an
116+
instance whose cached method has been called holds a
117+
``functools._lru_cache_wrapper`` object in its ``__dict__``, and that object
118+
is not picklable. If instances of the class need to be pickled, override
119+
``__getstate__`` to drop the cache attributes from the pickled state. The
120+
cache attribute names all begin with ``_lru_cache_``, so filtering on that
121+
prefix excludes every per-instance cache. The caches are recreated lazily on
122+
the next call after unpickling.
123+
124+
::
125+
126+
class MyClass:
127+
128+
@wrapt.lru_cache
129+
def compute(self, x):
130+
return x * 2
131+
132+
def __getstate__(self):
133+
return {
134+
key: value
135+
for key, value in self.__dict__.items()
136+
if not key.startswith("_lru_cache_")
137+
}
138+
139+
To target the cache for a particular method rather than all of them, filter
140+
on the more specific ``_lru_cache_<name>_`` prefix, where ``<name>`` is the
141+
method name. For example, ``_lru_cache_compute_`` matches only the cache for
142+
the ``compute`` method above.
143+
115144
Thread Synchronization
116145
----------------------
117146

docs/changes.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,42 @@
11
Release Notes
22
=============
33

4+
Version 2.2.2
5+
-------------
6+
7+
**Bugs Fixed**
8+
9+
* When ``@wrapt.lru_cache`` was applied to an instance method that was
10+
overridden in a subclass, and the subclass method called the base class
11+
method via ``super()``, a ``RecursionError`` was raised instead of the
12+
base class method being invoked. The per-instance cache for each method
13+
was stored as an attribute on the instance whose name was derived only
14+
from the method ``__name__``, so the base and derived methods shared a
15+
single cache slot. The subclass cache was therefore found again when the
16+
base method was reached through ``super()``, re-entering the subclass body
17+
and recursing without end. The cache attribute name now incorporates a
18+
unique identifier for each decorated method so that a base method and a
19+
method that overrides it use distinct per-instance caches. With thanks to
20+
the reporter of `issue #342
21+
<https://github.com/GrahamDumpleton/wrapt/issues/342>`_.
22+
23+
* When ``@wrapt.lru_cache`` was applied to a method of a class deriving from
24+
``wrapt.ObjectProxy``, the per-instance cache was stored on the wrapped
25+
object rather than on the proxy. This is because the proxy ``__setattr__``
26+
forwards attribute assignment to the wrapped object for any name that is
27+
not a recognised proxy attribute, and the cache attribute name was not one.
28+
Storing the cache on the wrapped object had several consequences: the
29+
wrapped object was polluted with cache attributes it never defined; the
30+
cache held a reference back to the proxy through the bound method it
31+
wrapped, so a wrapped object that outlived the proxy kept the proxy alive
32+
and prevented its collection; wrapping an object that does not accept
33+
arbitrary attributes, such as one using ``__slots__``, caused the first
34+
cached call to fail with an ``AttributeError``; and two proxies sharing a
35+
single wrapped object shared one cache and could return results computed
36+
for the wrong proxy. The cache attribute is now stored on the proxy itself
37+
using the proxy ``__self_setattr__`` method when the instance is a wrapt
38+
object proxy, falling back to ``setattr`` for ordinary instances.
39+
440
Version 2.2.1
541
-------------
642

docs/issues.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,25 @@ class whose metaclass is ``abc.ABCMeta`` will fail when used with
221221
# TypeError: descriptor '__subclasscheck__' for '_wrappers.ObjectProxy'
222222
# objects doesn't apply to a 'type' object
223223

224+
Under the pure Python implementation of ``ObjectProxy`` (used when the
225+
C extension is not available, including on PyPy or when
226+
``WRAPT_DISABLE_EXTENSIONS`` is set in the environment), the failure
227+
surfaces earlier, at the ``class Proxy(...)`` statement itself, with a
228+
different message::
229+
230+
TypeError: metaclass conflict: the metaclass of a derived class
231+
must be a (non-strict) subclass of the metaclasses of all its bases
232+
233+
The pure Python ``ObjectProxy`` carries a custom metaclass, used to
234+
make type-level access to ``__module__`` and ``__doc__`` return strings
235+
rather than property objects, whereas the C extension's ``ObjectProxy``
236+
has plain ``type`` as its metaclass. When a second base class with
237+
``ABCMeta`` as its metaclass is mixed in, the pure Python build cannot
238+
find a common metaclass and aborts class creation. The C build
239+
proceeds and only fails later at the first ``isinstance()`` call. The
240+
underlying problem is the same in both cases; only the point at which
241+
it surfaces differs.
242+
224243
The same failure occurs when the second base class is one of the
225244
abstract base classes exported from ``collections.abc`` (for example
226245
``Hashable``, ``Iterable``, ``Container``), since they too use

src/wrapt/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def _format_version(parts):
1313
)
1414

1515

16-
__version_info__ = ("2", "2", "1")
16+
__version_info__ = ("2", "2", "2")
1717
__version__ = _format_version(__version_info__)
1818

1919
from .__wrapt__ import (

src/wrapt/caching.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from functools import lru_cache as _functools_lru_cache
1010
from functools import partial
1111

12-
from .__wrapt__ import BoundFunctionWrapper, FunctionWrapper
12+
from .__wrapt__ import BaseObjectProxy, BoundFunctionWrapper, FunctionWrapper
1313
from .decorators import decorator
1414
from .synchronization import synchronized
1515

@@ -71,7 +71,19 @@ def __call__(self, *args, **kwargs):
7171
self.__wrapped__
7272
)
7373

74-
setattr(instance, cache_attr, cache)
74+
# If the instance the method is bound to is a wrapt
75+
# object proxy, a plain setattr() would fall through and
76+
# store the cache on the wrapped object rather than the
77+
# proxy. Use type() rather than isinstance() so the check
78+
# sees the real proxy type and is not fooled by the proxy
79+
# overriding __class__ to report the wrapped object's
80+
# type. Proxies expose __self_setattr__() which stores the
81+
# attribute on the proxy itself.
82+
83+
if issubclass(type(instance), BaseObjectProxy):
84+
instance.__self_setattr__(cache_attr, cache)
85+
else:
86+
setattr(instance, cache_attr, cache)
7587

7688
return cache(*args, **kwargs)
7789

@@ -137,7 +149,17 @@ def __init__(self, wrapped, wrapper, **kwargs):
137149
if name is None:
138150
name = wrapped.__func__.__name__
139151

140-
self._self_cache_attr = "_lru_cache_" + name
152+
# The cache attribute name must be unique per decorated method so
153+
# that a method overridden in a subclass does not share the same
154+
# per-instance cache slot as the method it overrides. If they shared
155+
# a slot, a subclass method calling super() would find the subclass
156+
# cache and re-enter its own body, recursing forever. The owning
157+
# class is not known here, since the decorator runs on the raw
158+
# function before the class exists, but each decorated method has its
159+
# own wrapper instance, so id(self) is a stable discriminator unique
160+
# to this method definition.
161+
162+
self._self_cache_attr = "_lru_cache_" + name + "_" + str(id(self))
141163

142164
def __call__(self, *args, **kwargs):
143165
# Plain function or static method — single cache stored

tests/core/test_lru_cache.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,161 @@ def test_access_via_instance(self):
267267
self.assertEqual(info.misses, 1)
268268

269269

270+
class OverrideBase:
271+
def __init__(self):
272+
self.base_calls = 0
273+
self.derived_calls = 0
274+
275+
@wrapt.lru_cache
276+
def compute(self, x):
277+
self.base_calls += 1
278+
return x * 2
279+
280+
281+
class OverrideDerived(OverrideBase):
282+
@wrapt.lru_cache
283+
def compute(self, x):
284+
self.derived_calls += 1
285+
return super().compute(x) + 1
286+
287+
288+
class TestOverriddenMethodWithSuper(unittest.TestCase):
289+
def test_super_call_returns_correct_result(self):
290+
obj = OverrideDerived()
291+
self.assertEqual(obj.compute(10), 21)
292+
293+
def test_super_call_does_not_recurse(self):
294+
# Regression test: a subclass method decorated with lru_cache that
295+
# called the base method via super() used to recurse forever because
296+
# the base and derived methods shared a single per-instance cache
297+
# slot derived from the method name alone.
298+
obj = OverrideDerived()
299+
try:
300+
result = obj.compute(10)
301+
except RecursionError:
302+
self.fail("super() call recursed instead of reaching base method")
303+
self.assertEqual(result, 21)
304+
305+
def test_both_bodies_execute_once(self):
306+
obj = OverrideDerived()
307+
obj.compute(10)
308+
self.assertEqual(obj.derived_calls, 1)
309+
self.assertEqual(obj.base_calls, 1)
310+
311+
def test_base_and_derived_cached_independently(self):
312+
obj = OverrideDerived()
313+
obj.compute(10)
314+
obj.compute(10)
315+
# Second call is served from both caches, so neither body re-runs.
316+
self.assertEqual(obj.derived_calls, 1)
317+
self.assertEqual(obj.base_calls, 1)
318+
info = obj.compute.cache_info()
319+
self.assertEqual(info.hits, 1)
320+
self.assertEqual(info.misses, 1)
321+
322+
def test_base_class_instance_unaffected(self):
323+
obj = OverrideBase()
324+
self.assertEqual(obj.compute(10), 20)
325+
self.assertEqual(obj.base_calls, 1)
326+
self.assertEqual(obj.derived_calls, 0)
327+
328+
def test_separate_instances_have_separate_caches(self):
329+
obj1 = OverrideDerived()
330+
obj2 = OverrideDerived()
331+
obj1.compute(10)
332+
obj2.compute(10)
333+
self.assertEqual(obj1.derived_calls, 1)
334+
self.assertEqual(obj2.derived_calls, 1)
335+
self.assertEqual(obj1.base_calls, 1)
336+
self.assertEqual(obj2.base_calls, 1)
337+
338+
339+
class ProxyWrapped:
340+
def __init__(self, a):
341+
self.a = a
342+
343+
344+
class ProxyCached(wrapt.ObjectProxy):
345+
@wrapt.lru_cache
346+
def compute(self, x):
347+
return self.a + x
348+
349+
350+
class ProxyCachedWithState(wrapt.ObjectProxy):
351+
def __init__(self, wrapped, factor):
352+
super().__init__(wrapped)
353+
self._self_factor = factor
354+
355+
@wrapt.lru_cache
356+
def compute(self, x):
357+
return self._self_factor * x
358+
359+
360+
class SlottedWrapped:
361+
__slots__ = ("a",)
362+
363+
def __init__(self, a):
364+
self.a = a
365+
366+
367+
class TestObjectProxySubclass(unittest.TestCase):
368+
def test_returns_correct_result(self):
369+
obj = ProxyCached(ProxyWrapped(1))
370+
self.assertEqual(obj.compute(10), 11)
371+
372+
def test_caching(self):
373+
obj = ProxyCached(ProxyWrapped(1))
374+
obj.compute(10)
375+
obj.compute(10)
376+
info = obj.compute.cache_info()
377+
self.assertEqual(info.hits, 1)
378+
self.assertEqual(info.misses, 1)
379+
380+
def test_cache_not_stored_on_wrapped_object(self):
381+
wrapped = ProxyWrapped(1)
382+
obj = ProxyCached(wrapped)
383+
obj.compute(10)
384+
cache_attrs = [k for k in vars(wrapped) if k.startswith("_lru_cache_")]
385+
self.assertEqual(cache_attrs, [])
386+
387+
def test_per_proxy_state_not_shared_for_same_wrapped_object(self):
388+
# Two proxies over the same wrapped object must keep independent
389+
# per-instance caches keyed to their own proxy state, not a single
390+
# cache stored on the shared wrapped object.
391+
wrapped = ProxyWrapped(1)
392+
obj1 = ProxyCachedWithState(wrapped, 2)
393+
obj2 = ProxyCachedWithState(wrapped, 10)
394+
self.assertEqual(obj1.compute(5), 10)
395+
self.assertEqual(obj2.compute(5), 50)
396+
397+
def test_proxy_garbage_collected_with_long_lived_wrapped(self):
398+
# The cache must be stored on the proxy, not the wrapped object, so
399+
# a proxy is not kept alive by a wrapped object that outlives it.
400+
registry = []
401+
402+
def make_and_use():
403+
backing = ProxyWrapped(7)
404+
registry.append(backing)
405+
proxy = ProxyCached(backing)
406+
proxy.compute(4)
407+
return weakref.ref(proxy)
408+
409+
ref = make_and_use()
410+
gc.collect()
411+
self.assertIsNone(ref())
412+
413+
def test_wrapped_object_without_dict(self):
414+
# A wrapped object that does not accept arbitrary attributes (for
415+
# example one using __slots__) must not cause the cache storage to
416+
# fail, since the cache is stored on the proxy.
417+
obj = ProxyCached(SlottedWrapped(1))
418+
self.assertEqual(obj.compute(10), 11)
419+
self.assertEqual(obj.compute(10), 11)
420+
info = obj.compute.cache_info()
421+
self.assertEqual(info.hits, 1)
422+
self.assertEqual(info.misses, 1)
423+
424+
270425
class TestIntrospection(unittest.TestCase):
271426
def test_function_name(self):
272427
self.assertEqual(cached_function.__name__, "cached_function")

0 commit comments

Comments
 (0)