Skip to content

Commit bb72c21

Browse files
committed
Fix #357: Add cache_info() support for @cachedmethod.
1 parent 86352ae commit bb72c21

6 files changed

Lines changed: 775 additions & 243 deletions

File tree

src/cachetools/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -697,14 +697,26 @@ def make_info(hits, misses):
697697
return decorator
698698

699699

700-
def cachedmethod(cache, key=keys.methodkey, lock=None, condition=None):
700+
def cachedmethod(cache, key=keys.methodkey, lock=None, condition=None, info=False):
701701
"""Decorator to wrap a class or instance method with a memoizing
702702
callable that saves results in a cache.
703703
704704
"""
705705
from ._cachedmethod import _wrapper
706706

707707
def decorator(method):
708-
return _wrapper(method, cache, key, lock, condition)
708+
if info:
709+
710+
def make_info(cache, hits, misses):
711+
if isinstance(cache, Cache):
712+
return _CacheInfo(hits, misses, cache.maxsize, cache.currsize)
713+
elif isinstance(cache, collections.abc.Mapping):
714+
return _CacheInfo(hits, misses, None, len(cache))
715+
else:
716+
raise TypeError("cache(self) must return a mutable mapping")
717+
718+
return _wrapper(method, cache, key, lock, condition, info=make_info)
719+
else:
720+
return _wrapper(method, cache, key, lock, condition)
709721

710722
return decorator

src/cachetools/_cachedmethod.py

Lines changed: 182 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
"""Method decorator helpers."""
22

33
import functools
4+
import warnings
45
import weakref
56

67

78
def warn_classmethod(stacklevel):
8-
from warnings import warn
9-
10-
warn(
9+
warnings.warn(
1110
"decorating class methods with @cachedmethod is deprecated",
1211
DeprecationWarning,
1312
stacklevel=stacklevel,
1413
)
1514

1615

16+
def warn_instance_dict(msg, stacklevel):
17+
warnings.warn(
18+
msg,
19+
DeprecationWarning,
20+
stacklevel=stacklevel,
21+
)
22+
23+
1724
class WrapperBase:
1825
def __init__(self, obj, method, cache, key, lock=None, cond=None):
19-
if type(obj) is type:
26+
if isinstance(obj, type):
2027
warn_classmethod(stacklevel=5)
2128
functools.update_wrapper(self, method)
2229
self._obj = obj # protected
@@ -37,7 +44,7 @@ def cache(self):
3744

3845
@property
3946
def cache_key(self):
40-
return self.__key # TODO: how to handle self?
47+
return self.__key
4148

4249
@property
4350
def cache_lock(self):
@@ -49,43 +56,58 @@ def cache_condition(self):
4956

5057

5158
class DescriptorBase:
52-
def __init__(self, wrapper, cache_clear):
59+
def __init__(self, warndict=False):
5360
self.__attrname = None
54-
self.__wrapper = wrapper
55-
self.__cache_clear = cache_clear
61+
self.__warndict = warndict
5662

5763
def __set_name__(self, owner, name):
5864
if self.__attrname is None:
5965
self.__attrname = name
60-
elif name != self.__attrname: # pragma: no cover
66+
elif name != self.__attrname:
6167
raise TypeError(
6268
"Cannot assign the same @cachedmethod to two different names "
6369
f"({self.__attrname!r} and {name!r})."
6470
)
6571

6672
def __get__(self, obj, objtype=None):
67-
if obj is None:
68-
return self # deprecated @classmethod
6973
wrapper = self.Wrapper(obj)
7074
if self.__attrname is not None:
7175
try:
7276
wrapper = obj.__dict__.setdefault(self.__attrname, wrapper)
73-
except AttributeError: # pragma: no cover
77+
except AttributeError:
7478
# not all objects have __dict__ (e.g. class defines slots)
7579
msg = (
7680
f"No '__dict__' attribute on {type(obj).__name__!r} "
7781
f"instance to cache {self.__attrname!r} property."
7882
)
79-
raise TypeError(msg) from None
80-
except TypeError: # pragma: no cover
83+
if self.__warndict:
84+
warn_instance_dict(msg, 3)
85+
else:
86+
raise TypeError(msg) from None
87+
except TypeError:
8188
msg = (
8289
f"The '__dict__' attribute on {type(obj).__name__!r} "
8390
f"instance does not support item assignment for "
8491
f"caching {self.__attrname!r} property."
8592
)
86-
raise TypeError(msg) from None
93+
if self.__warndict:
94+
warn_instance_dict(msg, 3)
95+
else:
96+
raise TypeError(msg) from None
97+
elif self.__deprecated:
98+
pass # deprecated @classmethod, warning already raised elsewhere
99+
else:
100+
msg = "Cannot use @cachedmethod instance without calling __set_name__ on it"
101+
raise TypeError(msg) from None
87102
return wrapper
88103

104+
105+
class DeprecatedDescriptorBase(DescriptorBase):
106+
def __init__(self, wrapper, cache_clear):
107+
super().__init__(True)
108+
self.__wrapper = wrapper
109+
self.__cache_clear = cache_clear
110+
89111
# called for @classmethod with Python >= 3.13
90112
def __call__(self, *args, **kwargs):
91113
warn_classmethod(stacklevel=3)
@@ -97,6 +119,127 @@ def cache_clear(self, objtype):
97119
return self.__cache_clear(objtype)
98120

99121

122+
def _condition_info(method, cache, key, lock, cond, info):
123+
class Descriptor(DescriptorBase):
124+
class Wrapper(WrapperBase):
125+
def __init__(self, obj):
126+
super().__init__(obj, method, cache, key, lock, cond)
127+
self.__hits = self.__misses = 0
128+
self.__pending = set()
129+
130+
def __call__(self, *args, **kwargs):
131+
cache = self.cache
132+
lock = self.cache_lock
133+
cond = self.cache_condition
134+
key = self.cache_key(self._obj, *args, **kwargs)
135+
136+
with lock:
137+
cond.wait_for(lambda: key not in self.__pending)
138+
try:
139+
result = cache[key]
140+
self.__hits += 1
141+
return result
142+
except KeyError:
143+
self.__pending.add(key)
144+
self.__misses += 1
145+
try:
146+
val = method(self._obj, *args, **kwargs)
147+
with lock:
148+
try:
149+
cache[key] = val
150+
except ValueError:
151+
pass # value too large
152+
return val
153+
finally:
154+
with lock:
155+
self.__pending.remove(key)
156+
cond.notify_all()
157+
158+
def cache_clear(self):
159+
with self.cache_lock:
160+
self.cache.clear()
161+
self.__hits = self.__misses = 0
162+
163+
def cache_info(self):
164+
with self.cache_lock:
165+
return info(self.cache, self.__hits, self.__misses)
166+
167+
return Descriptor()
168+
169+
170+
def _locked_info(method, cache, key, lock, info):
171+
class Descriptor(DescriptorBase):
172+
class Wrapper(WrapperBase):
173+
def __init__(self, obj):
174+
super().__init__(obj, method, cache, key, lock)
175+
self.__hits = self.__misses = 0
176+
177+
def __call__(self, *args, **kwargs):
178+
cache = self.cache
179+
lock = self.cache_lock
180+
key = self.cache_key(self._obj, *args, **kwargs)
181+
with lock:
182+
try:
183+
result = cache[key]
184+
self.__hits += 1
185+
return result
186+
except KeyError:
187+
self.__misses += 1
188+
val = method(self._obj, *args, **kwargs)
189+
with lock:
190+
try:
191+
# In case of a race condition, i.e. if another thread
192+
# stored a value for this key while we were calling
193+
# method(), prefer the cached value.
194+
return cache.setdefault(key, val)
195+
except ValueError:
196+
return val # value too large
197+
198+
def cache_clear(self):
199+
with self.cache_lock:
200+
self.cache.clear()
201+
self.__hits = self.__misses = 0
202+
203+
def cache_info(self):
204+
with self.cache_lock:
205+
return info(self.cache, self.__hits, self.__misses)
206+
207+
return Descriptor()
208+
209+
210+
def _unlocked_info(method, cache, key, info):
211+
class Descriptor(DescriptorBase):
212+
class Wrapper(WrapperBase):
213+
def __init__(self, obj):
214+
super().__init__(obj, method, cache, key)
215+
self.__hits = self.__misses = 0
216+
217+
def __call__(self, *args, **kwargs):
218+
cache = self.cache
219+
key = self.cache_key(self._obj, *args, **kwargs)
220+
try:
221+
result = cache[key]
222+
self.__hits += 1
223+
return result
224+
except KeyError:
225+
self.__misses += 1
226+
val = method(self._obj, *args, **kwargs)
227+
try:
228+
cache[key] = val
229+
except ValueError:
230+
pass # value too large
231+
return val
232+
233+
def cache_clear(self):
234+
self.cache.clear()
235+
self.__hits = self.__misses = 0
236+
237+
def cache_info(self):
238+
return info(self.cache, self.__hits, self.__misses)
239+
240+
return Descriptor()
241+
242+
100243
def _condition(method, cache, key, lock, cond):
101244
# backward-compatible weakref dictionary for Python >= 3.13
102245
pending = weakref.WeakKeyDictionary()
@@ -132,7 +275,7 @@ def classmethod_wrapper(self, *args, **kwargs):
132275
p = pending.setdefault(self, set())
133276
return wrapper(self, p, *args, **kwargs)
134277

135-
class Descriptor(DescriptorBase):
278+
class Descriptor(DeprecatedDescriptorBase):
136279
class Wrapper(WrapperBase):
137280
def __init__(self, obj):
138281
super().__init__(obj, method, cache, key, lock, cond)
@@ -158,9 +301,9 @@ def wrapper(self, *args, **kwargs):
158301
except KeyError:
159302
pass # key not found
160303
v = method(self, *args, **kwargs)
161-
# in case of a race, prefer the item already in the cache
162304
with lock(self):
163305
try:
306+
# possible race condition: see above
164307
return c.setdefault(k, v)
165308
except ValueError:
166309
return v # value too large
@@ -170,7 +313,7 @@ def cache_clear(self):
170313
with lock(self):
171314
c.clear()
172315

173-
class Descriptor(DescriptorBase):
316+
class Descriptor(DeprecatedDescriptorBase):
174317
class Wrapper(WrapperBase):
175318
def __init__(self, obj):
176319
super().__init__(obj, method, cache, key, lock)
@@ -204,7 +347,7 @@ def cache_clear(self):
204347
c = cache(self)
205348
c.clear()
206349

207-
class Descriptor(DescriptorBase):
350+
class Descriptor(DeprecatedDescriptorBase):
208351
class Wrapper(WrapperBase):
209352
def __init__(self, obj):
210353
super().__init__(obj, method, cache, key)
@@ -219,17 +362,27 @@ def cache_clear(self, _objtype=None):
219362
return Descriptor(wrapper, cache_clear)
220363

221364

222-
def _wrapper(method, cache, key, lock=None, cond=None):
223-
if cond is not None and lock is not None:
224-
wrapper = _condition(method, cache, key, lock, cond)
225-
elif cond is not None:
226-
wrapper = _condition(method, cache, key, cond, cond)
227-
elif lock is not None:
228-
wrapper = _locked(method, cache, key, lock)
365+
def _wrapper(method, cache, key, lock=None, cond=None, info=None):
366+
if info is not None:
367+
if cond is not None and lock is not None:
368+
wrapper = _condition_info(method, cache, key, lock, cond, info)
369+
elif cond is not None:
370+
wrapper = _condition_info(method, cache, key, cond, cond, info)
371+
elif lock is not None:
372+
wrapper = _locked_info(method, cache, key, lock, info)
373+
else:
374+
wrapper = _unlocked_info(method, cache, key, info)
229375
else:
230-
wrapper = _unlocked(method, cache, key)
231-
232-
# backward-compatible properties for @classmethod
376+
if cond is not None and lock is not None:
377+
wrapper = _condition(method, cache, key, lock, cond)
378+
elif cond is not None:
379+
wrapper = _condition(method, cache, key, cond, cond)
380+
elif lock is not None:
381+
wrapper = _locked(method, cache, key, lock)
382+
else:
383+
wrapper = _unlocked(method, cache, key)
384+
385+
# backward-compatible properties for deprecated @classmethod use
233386
wrapper.cache = cache
234387
wrapper.cache_key = key
235388
wrapper.cache_lock = lock if lock is not None else cond

tests/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,11 +317,15 @@ def __exit__(self, *exc):
317317

318318
class CountedCondition(CountedLock):
319319
def __init__(self):
320-
CountedLock.__init__(self)
320+
super().__init__()
321321
self.wait_count = 0
322322
self.notify_count = 0
323323

324+
# only wait_for() and notify_all() are used in the cache tests,
325+
# calling wait() or notify() will fail intentionally
326+
324327
def wait_for(self, predicate):
328+
assert callable(predicate)
325329
self.wait_count += 1
326330

327331
def notify_all(self):

0 commit comments

Comments
 (0)