Skip to content

Commit 86352ae

Browse files
committed
Fix #357: Convert @cachedmethod decorators to descriptors.
1 parent 263cf31 commit 86352ae

5 files changed

Lines changed: 401 additions & 176 deletions

File tree

src/cachetools/_cachedmethod.py

Lines changed: 143 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,112 @@
44
import weakref
55

66

7+
def warn_classmethod(stacklevel):
8+
from warnings import warn
9+
10+
warn(
11+
"decorating class methods with @cachedmethod is deprecated",
12+
DeprecationWarning,
13+
stacklevel=stacklevel,
14+
)
15+
16+
17+
class WrapperBase:
18+
def __init__(self, obj, method, cache, key, lock=None, cond=None):
19+
if type(obj) is type:
20+
warn_classmethod(stacklevel=5)
21+
functools.update_wrapper(self, method)
22+
self._obj = obj # protected
23+
self.__cache = cache
24+
self.__key = key
25+
self.__lock = lock
26+
self.__cond = cond
27+
28+
def __call__(self, *args, **kwargs):
29+
raise NotImplementedError() # pragma: no cover
30+
31+
def cache_clear(self):
32+
raise NotImplementedError() # pragma: no cover
33+
34+
@property
35+
def cache(self):
36+
return self.__cache(self._obj)
37+
38+
@property
39+
def cache_key(self):
40+
return self.__key # TODO: how to handle self?
41+
42+
@property
43+
def cache_lock(self):
44+
return None if self.__lock is None else self.__lock(self._obj)
45+
46+
@property
47+
def cache_condition(self):
48+
return None if self.__cond is None else self.__cond(self._obj)
49+
50+
51+
class DescriptorBase:
52+
def __init__(self, wrapper, cache_clear):
53+
self.__attrname = None
54+
self.__wrapper = wrapper
55+
self.__cache_clear = cache_clear
56+
57+
def __set_name__(self, owner, name):
58+
if self.__attrname is None:
59+
self.__attrname = name
60+
elif name != self.__attrname: # pragma: no cover
61+
raise TypeError(
62+
"Cannot assign the same @cachedmethod to two different names "
63+
f"({self.__attrname!r} and {name!r})."
64+
)
65+
66+
def __get__(self, obj, objtype=None):
67+
if obj is None:
68+
return self # deprecated @classmethod
69+
wrapper = self.Wrapper(obj)
70+
if self.__attrname is not None:
71+
try:
72+
wrapper = obj.__dict__.setdefault(self.__attrname, wrapper)
73+
except AttributeError: # pragma: no cover
74+
# not all objects have __dict__ (e.g. class defines slots)
75+
msg = (
76+
f"No '__dict__' attribute on {type(obj).__name__!r} "
77+
f"instance to cache {self.__attrname!r} property."
78+
)
79+
raise TypeError(msg) from None
80+
except TypeError: # pragma: no cover
81+
msg = (
82+
f"The '__dict__' attribute on {type(obj).__name__!r} "
83+
f"instance does not support item assignment for "
84+
f"caching {self.__attrname!r} property."
85+
)
86+
raise TypeError(msg) from None
87+
return wrapper
88+
89+
# called for @classmethod with Python >= 3.13
90+
def __call__(self, *args, **kwargs):
91+
warn_classmethod(stacklevel=3)
92+
return self.__wrapper(*args, **kwargs)
93+
94+
# backward-compatible @classmethod handling with Python >= 3.13
95+
def cache_clear(self, objtype):
96+
warn_classmethod(stacklevel=3)
97+
return self.__cache_clear(objtype)
98+
99+
7100
def _condition(method, cache, key, lock, cond):
101+
# backward-compatible weakref dictionary for Python >= 3.13
8102
pending = weakref.WeakKeyDictionary()
9103

10-
def wrapper(self, *args, **kwargs):
104+
def wrapper(self, pending, *args, **kwargs):
11105
c = cache(self)
12106
k = key(self, *args, **kwargs)
13107
with lock(self):
14-
p = pending.setdefault(self, set())
15-
cond(self).wait_for(lambda: k not in p)
108+
cond(self).wait_for(lambda: k not in pending)
16109
try:
17110
return c[k]
18111
except KeyError:
19-
p.add(k)
112+
pending.add(k)
20113
try:
21114
v = method(self, *args, **kwargs)
22115
with lock(self):
@@ -27,16 +120,32 @@ def wrapper(self, *args, **kwargs):
27120
return v
28121
finally:
29122
with lock(self):
30-
pending[self].remove(k)
123+
pending.remove(k)
31124
cond(self).notify_all()
32125

33126
def cache_clear(self):
34127
c = cache(self)
35128
with lock(self):
36129
c.clear()
37130

38-
wrapper.cache_clear = cache_clear
39-
return wrapper
131+
def classmethod_wrapper(self, *args, **kwargs):
132+
p = pending.setdefault(self, set())
133+
return wrapper(self, p, *args, **kwargs)
134+
135+
class Descriptor(DescriptorBase):
136+
class Wrapper(WrapperBase):
137+
def __init__(self, obj):
138+
super().__init__(obj, method, cache, key, lock, cond)
139+
self.__pending = set()
140+
141+
def __call__(self, *args, **kwargs):
142+
return wrapper(self._obj, self.__pending, *args, **kwargs)
143+
144+
# objtype: backward-compatible @classmethod handling with Python < 3.13
145+
def cache_clear(self, _objtype=None):
146+
return cache_clear(self._obj)
147+
148+
return Descriptor(classmethod_wrapper, cache_clear)
40149

41150

42151
def _locked(method, cache, key, lock):
@@ -61,8 +170,19 @@ def cache_clear(self):
61170
with lock(self):
62171
c.clear()
63172

64-
wrapper.cache_clear = cache_clear
65-
return wrapper
173+
class Descriptor(DescriptorBase):
174+
class Wrapper(WrapperBase):
175+
def __init__(self, obj):
176+
super().__init__(obj, method, cache, key, lock)
177+
178+
def __call__(self, *args, **kwargs):
179+
return wrapper(self._obj, *args, **kwargs)
180+
181+
# objtype: backward-compatible @classmethod handling with Python < 3.13
182+
def cache_clear(self, _objtype=None):
183+
return cache_clear(self._obj)
184+
185+
return Descriptor(wrapper, cache_clear)
66186

67187

68188
def _unlocked(method, cache, key):
@@ -84,8 +204,19 @@ def cache_clear(self):
84204
c = cache(self)
85205
c.clear()
86206

87-
wrapper.cache_clear = cache_clear
88-
return wrapper
207+
class Descriptor(DescriptorBase):
208+
class Wrapper(WrapperBase):
209+
def __init__(self, obj):
210+
super().__init__(obj, method, cache, key)
211+
212+
def __call__(self, *args, **kwargs):
213+
return wrapper(self._obj, *args, **kwargs)
214+
215+
# objtype: backward-compatible @classmethod handling with Python < 3.13
216+
def cache_clear(self, _objtype=None):
217+
return cache_clear(self._obj)
218+
219+
return Descriptor(wrapper, cache_clear)
89220

90221

91222
def _wrapper(method, cache, key, lock=None, cond=None):
@@ -98,6 +229,7 @@ def _wrapper(method, cache, key, lock=None, cond=None):
98229
else:
99230
wrapper = _unlocked(method, cache, key)
100231

232+
# backward-compatible properties for @classmethod
101233
wrapper.cache = cache
102234
wrapper.cache_key = key
103235
wrapper.cache_lock = lock if lock is not None else cond

tests/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,3 +302,27 @@ def test_pickle_maxsize(self):
302302
cache = pickle.loads(pickle.dumps(source))
303303
self.assertEqual(n, len(cache))
304304
self.assertEqual(source, cache)
305+
306+
307+
class CountedLock:
308+
def __init__(self):
309+
self.count = 0
310+
311+
def __enter__(self):
312+
self.count += 1
313+
314+
def __exit__(self, *exc):
315+
pass
316+
317+
318+
class CountedCondition(CountedLock):
319+
def __init__(self):
320+
CountedLock.__init__(self)
321+
self.wait_count = 0
322+
self.notify_count = 0
323+
324+
def wait_for(self, predicate):
325+
self.wait_count += 1
326+
327+
def notify_all(self):
328+
self.notify_count += 1

tests/test_cached.py

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,7 @@
44
import cachetools
55
import cachetools.keys
66

7-
8-
class CountedLock:
9-
def __init__(self):
10-
self.count = 0
11-
12-
def __enter__(self):
13-
self.count += 1
14-
15-
def __exit__(self, *exc):
16-
pass
17-
18-
19-
class CountedCondition(CountedLock):
20-
def __init__(self):
21-
CountedLock.__init__(self)
22-
self.wait_count = 0
23-
self.notify_count = 0
24-
25-
def wait_for(self, predicate):
26-
self.wait_count += 1
27-
28-
def notify_all(self):
29-
self.notify_count += 1
7+
from . import CountedCondition, CountedLock
308

319

3210
class DecoratorTestMixin:

0 commit comments

Comments
 (0)