Visitar URL original
Store lru_cache per-instance cache on object proxy not wrapped object. · GrahamDumpleton/wrapt@99bf4ef · GitHub
Skip to content

Commit 99bf4ef

Browse files
Store lru_cache per-instance cache on object proxy not wrapped object.
When @wrapt.lru_cache was applied to a method of a class deriving from wrapt.ObjectProxy, the per-instance cache was stored on the wrapped object rather than the proxy, because the proxy __setattr__ forwards assignment of unrecognised attribute names to the wrapped object. This polluted the wrapped object, kept the proxy alive whenever the wrapped object outlived it, failed with AttributeError when the wrapped object did not accept arbitrary attributes such as one using __slots__, and let two proxies over a single wrapped object share one cache and return results computed for the wrong proxy. The cache attribute is now stored on the proxy itself via the proxy __self_setattr__ method when the instance is a wrapt object proxy, falling back to setattr for ordinary instances. Add regression tests covering correctness, lack of wrapped object pollution, garbage collection, per-proxy state, and slotted wrapped objects.
1 parent 0e862fb commit 99bf4ef

3 files changed

Lines changed: 116 additions & 1 deletion

File tree

‎docs/changes.rst‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ Version 2.2.2
2020
the reporter of `issue #342
2121
<https://github.com/GrahamDumpleton/wrapt/issues/342>`_.
2222

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+
2340
Version 2.2.1
2441
-------------
2542

‎src/wrapt/caching.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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. Proxies expose __self_setattr__() which stores
78+
# the attribute on the proxy itself, so use it when it is
79+
# available and fall back to setattr() otherwise.
80+
81+
set_attr = getattr(instance, "__self_setattr__", None)
82+
83+
if set_attr is not None:
84+
set_attr(cache_attr, cache)
85+
else:
86+
setattr(instance, cache_attr, cache)
7587

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

‎tests/core/test_lru_cache.py‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,92 @@ def test_separate_instances_have_separate_caches(self):
336336
self.assertEqual(obj2.base_calls, 1)
337337

338338

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+
339425
class TestIntrospection(unittest.TestCase):
340426
def test_function_name(self):
341427
self.assertEqual(cached_function.__name__, "cached_function")

0 commit comments

Comments
 (0)