cachetools 7.2.1__tar.gz → 7.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {cachetools-7.2.1 → cachetools-7.2.3}/CHANGELOG.rst +23 -0
  2. {cachetools-7.2.1/src/cachetools.egg-info → cachetools-7.2.3}/PKG-INFO +1 -1
  3. {cachetools-7.2.1 → cachetools-7.2.3}/pyproject.toml +1 -0
  4. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools/__init__.py +3 -2
  5. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools/__init__.pyi +56 -0
  6. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools/_cached.py +13 -2
  7. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools/_cachedmethod.py +7 -98
  8. cachetools-7.2.3/src/cachetools/_descriptor.py +126 -0
  9. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools/func.pyi +28 -2
  10. {cachetools-7.2.1 → cachetools-7.2.3/src/cachetools.egg-info}/PKG-INFO +1 -1
  11. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools.egg-info/SOURCES.txt +1 -0
  12. {cachetools-7.2.1 → cachetools-7.2.3}/tests/__init__.py +2 -2
  13. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_cached.py +60 -2
  14. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_cachedmethod.py +112 -4
  15. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_func.py +69 -2
  16. cachetools-7.2.3/tests/test_threading.py +103 -0
  17. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_ttl.py +16 -0
  18. cachetools-7.2.1/tests/test_threading.py +0 -122
  19. {cachetools-7.2.1 → cachetools-7.2.3}/LICENSE +0 -0
  20. {cachetools-7.2.1 → cachetools-7.2.3}/MANIFEST.in +0 -0
  21. {cachetools-7.2.1 → cachetools-7.2.3}/README.rst +0 -0
  22. {cachetools-7.2.1 → cachetools-7.2.3}/docs/conf.py +0 -0
  23. {cachetools-7.2.1 → cachetools-7.2.3}/docs/index.rst +0 -0
  24. {cachetools-7.2.1 → cachetools-7.2.3}/setup.cfg +0 -0
  25. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools/func.py +0 -0
  26. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools/keys.py +0 -0
  27. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools/keys.pyi +0 -0
  28. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools/py.typed +0 -0
  29. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools.egg-info/dependency_links.txt +0 -0
  30. {cachetools-7.2.1 → cachetools-7.2.3}/src/cachetools.egg-info/top_level.txt +0 -0
  31. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_cache.py +0 -0
  32. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_classmethod.py +0 -0
  33. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_fifo.py +0 -0
  34. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_keys.py +0 -0
  35. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_lfu.py +0 -0
  36. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_lru.py +0 -0
  37. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_rr.py +0 -0
  38. {cachetools-7.2.1 → cachetools-7.2.3}/tests/test_tlru.py +0 -0
  39. {cachetools-7.2.1 → cachetools-7.2.3}/tox.ini +0 -0
@@ -1,3 +1,26 @@
1
+ v7.2.3 (2026-10-17)
2
+ ===================
3
+
4
+ - Fix ``@cached`` decorator stacking behavior.
5
+
6
+ - Minor type stub improvements.
7
+
8
+
9
+ v7.2.2 (2026-10-15)
10
+ ===================
11
+
12
+ - Fix ``@cachedmethod`` for subclasses that override a decorated
13
+ method and call the base class implementation via ``super()``.
14
+
15
+ - Restore class access to instance methods decorated with
16
+ ``@cachedmethod``.
17
+
18
+ - Improve ``TTLCache`` robustness when calculating expiration time
19
+ fails.
20
+
21
+ - Various unit test improvements.
22
+
23
+
1
24
  v7.2.1 (2026-10-05)
2
25
  ===================
3
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachetools
3
- Version: 7.2.1
3
+ Version: 7.2.3
4
4
  Summary: Extensible memoizing collections and decorators
5
5
  Author-email: Thomas Kemmer <tkemmer@computer.org>
6
6
  Maintainer-email: Thomas Kemmer <tkemmer@computer.org>
@@ -50,6 +50,7 @@ version = {attr = "cachetools.__version__"}
50
50
 
51
51
  [tool.pyright]
52
52
  typeCheckingMode = "standard"
53
+ reportUnnecessaryTypeIgnoreComment = "error"
53
54
 
54
55
  [tool.ruff.lint]
55
56
  ignore = ["DTZ005", "UP031"]
@@ -12,7 +12,7 @@ __all__ = (
12
12
  "cachedmethod",
13
13
  )
14
14
 
15
- __version__ = "7.2.1"
15
+ __version__ = "7.2.3"
16
16
 
17
17
  import collections
18
18
  import collections.abc
@@ -495,6 +495,7 @@ class TTLCache(_TimedCache):
495
495
 
496
496
  def __setitem__(self, key, value, cache_setitem=Cache.__setitem__):
497
497
  with self.timer as time:
498
+ expires = time + self.__ttl
498
499
  self.expire(time)
499
500
  cache_setitem(self, key, value)
500
501
  try:
@@ -503,7 +504,7 @@ class TTLCache(_TimedCache):
503
504
  self.__links[key] = link = TTLCache._Link(key)
504
505
  else:
505
506
  link.unlink()
506
- link.expires = time + self.__ttl
507
+ link.expires = expires
507
508
  link.next = root = self.__root
508
509
  link.prev = prev = root.prev
509
510
  prev.next = root.prev = link
@@ -29,7 +29,9 @@ __all__: Final = (
29
29
  __version__: str
30
30
 
31
31
  _P = ParamSpec("_P")
32
+ _Q = ParamSpec("_Q")
32
33
  _R = TypeVar("_R")
34
+ _S = TypeVar("_S")
33
35
  _T = TypeVar("_T")
34
36
  _KT = TypeVar("_KT")
35
37
  _VT = TypeVar("_VT")
@@ -153,16 +155,47 @@ class _AbstractCondition(AbstractContextManager[Any], Protocol):
153
155
  class _cached_wrapper(Generic[_P, _R]):
154
156
  __wrapped__: Callable[_P, _R]
155
157
  __name__: str
158
+ __qualname__: str
156
159
  __doc__: str | None
157
160
  cache: MutableMapping[Any, Any] | None
158
161
  cache_key: Callable[..., Any] = ...
159
162
  cache_lock: AbstractContextManager[Any] | None = None
160
163
  cache_condition: _AbstractCondition | None = None
164
+ @overload # class access, e.g. Cls.method
165
+ def __get__(
166
+ self, obj: None, objtype: type | None = None
167
+ ) -> _cached_wrapper[_P, _R]: ...
168
+ @overload # instance access binds the first parameter
169
+ def __get__(
170
+ self: _cached_wrapper[Concatenate[_S, _Q], _R],
171
+ obj: _S,
172
+ objtype: type | None = None,
173
+ ) -> _cached_wrapper[_Q, _R]: ...
174
+ @overload # fallback, e.g. when wrapped by @staticmethod or @classmethod
175
+ def __get__(
176
+ self, obj: object, objtype: type | None = None
177
+ ) -> _cached_wrapper[_P, _R]: ...
161
178
  def __call__(self, /, *args: _P.args, **kwargs: _P.kwargs) -> _R: ...
162
179
  def cache_clear(self) -> None: ...
163
180
 
164
181
  @type_check_only
165
182
  class _cached_wrapper_info(_cached_wrapper[_P, _R]):
183
+ @overload
184
+ def __get__(
185
+ self, obj: None, objtype: type | None = None
186
+ ) -> _cached_wrapper_info[_P, _R]: ...
187
+ @overload
188
+ def __get__(
189
+ self: _cached_wrapper_info[Concatenate[_S, _Q], _R],
190
+ obj: _S,
191
+ objtype: type | None = None,
192
+ ) -> _cached_wrapper_info[_Q, _R]: ...
193
+ # pyright cannot match the self-typed base overload; each return type is
194
+ # the _cached_wrapper_info subtype of the corresponding base return type
195
+ @overload
196
+ def __get__( # pyright: ignore[reportIncompatibleMethodOverride]
197
+ self, obj: object, objtype: type | None = None
198
+ ) -> _cached_wrapper_info[_P, _R]: ...
166
199
  def cache_info(self) -> _CacheInfo: ...
167
200
 
168
201
  @overload
@@ -191,16 +224,34 @@ def cached(
191
224
  info: Literal[True],
192
225
  ) -> Callable[[Callable[_P, _R]], _cached_wrapper_info[_P, _R]]: ...
193
226
 
227
+ @type_check_only
228
+ class _cachedmethod_unbound(Generic[_P, _R]):
229
+ __wrapped__: Callable[Concatenate[Any, _P], _R]
230
+ __name__: str
231
+ __qualname__: str
232
+ __doc__: str | None
233
+ @overload
234
+ def __call__(self, obj: Any, /, *args: _P.args, **kwargs: _P.kwargs) -> _R: ...
235
+ # deprecated @classmethod use
236
+ @overload
237
+ def __call__(self, /, *args: _P.args, **kwargs: _P.kwargs) -> _R: ...
238
+
194
239
  @type_check_only
195
240
  class _cachedmethod_wrapper(Generic[_P, _R]):
196
241
  __wrapped__: Callable[Concatenate[Any, _P], _R]
197
242
  __name__: str
243
+ __qualname__: str
198
244
  __doc__: str | None
199
245
  cache: MutableMapping[Any, Any]
200
246
  cache_key: Callable[..., Any] = ...
201
247
  cache_lock: AbstractContextManager[Any] | None = None
202
248
  cache_condition: _AbstractCondition | None = None
203
249
  def __set_name__(self, owner: type, name: str) -> None: ...
250
+ @overload
251
+ def __get__(
252
+ self, obj: None, objtype: type | None = None
253
+ ) -> _cachedmethod_unbound[_P, _R]: ...
254
+ @overload
204
255
  def __get__(
205
256
  self, obj: Any, objtype: type | None = None
206
257
  ) -> _cachedmethod_wrapper[_P, _R]: ...
@@ -209,6 +260,11 @@ class _cachedmethod_wrapper(Generic[_P, _R]):
209
260
 
210
261
  @type_check_only
211
262
  class _cachedmethod_wrapper_info(_cachedmethod_wrapper[_P, _R]):
263
+ @overload
264
+ def __get__(
265
+ self, obj: None, objtype: type | None = None
266
+ ) -> _cachedmethod_unbound[_P, _R]: ...
267
+ @overload
212
268
  def __get__(
213
269
  self, obj: Any, objtype: type | None = None
214
270
  ) -> _cachedmethod_wrapper_info[_P, _R]: ...
@@ -11,6 +11,10 @@ import warnings
11
11
  # over ease of maintenance, thus providing separate wrappers for
12
12
  # all valid combinations of decorator parameters lock, condition and
13
13
  # info.
14
+ #
15
+ # Each wrapper calls functools.update_wrapper() before setting its own
16
+ # attributes, so func.__dict__ cannot override wrapper state, e.g. when
17
+ # stacking decorators.
14
18
 
15
19
 
16
20
  def _condition_info(func, cache, key, lock, cond, info):
@@ -52,6 +56,7 @@ def _condition_info(func, cache, key, lock, cond, info):
52
56
  with lock:
53
57
  return info(hits, misses)
54
58
 
59
+ functools.update_wrapper(wrapper, func)
55
60
  wrapper.cache_clear = cache_clear
56
61
  wrapper.cache_info = cache_info
57
62
  return wrapper
@@ -90,6 +95,7 @@ def _locked_info(func, cache, key, lock, info):
90
95
  with lock:
91
96
  return info(hits, misses)
92
97
 
98
+ functools.update_wrapper(wrapper, func)
93
99
  wrapper.cache_clear = cache_clear
94
100
  wrapper.cache_info = cache_info
95
101
  return wrapper
@@ -122,6 +128,7 @@ def _unlocked_info(func, cache, key, info):
122
128
  def cache_info():
123
129
  return info(hits, misses)
124
130
 
131
+ functools.update_wrapper(wrapper, func)
125
132
  wrapper.cache_clear = cache_clear
126
133
  wrapper.cache_info = cache_info
127
134
  return wrapper
@@ -139,6 +146,7 @@ def _uncached_info(func, info):
139
146
  nonlocal misses
140
147
  misses = 0
141
148
 
149
+ functools.update_wrapper(wrapper, func)
142
150
  wrapper.cache_clear = cache_clear
143
151
  wrapper.cache_info = lambda: info(0, misses)
144
152
  return wrapper
@@ -173,6 +181,7 @@ def _condition(func, cache, key, lock, cond):
173
181
  with lock:
174
182
  cache.clear()
175
183
 
184
+ functools.update_wrapper(wrapper, func)
176
185
  wrapper.cache_clear = cache_clear
177
186
  return wrapper
178
187
 
@@ -199,6 +208,7 @@ def _locked(func, cache, key, lock):
199
208
  with lock:
200
209
  cache.clear()
201
210
 
211
+ functools.update_wrapper(wrapper, func)
202
212
  wrapper.cache_clear = cache_clear
203
213
  return wrapper
204
214
 
@@ -217,6 +227,7 @@ def _unlocked(func, cache, key):
217
227
  pass # value too large
218
228
  return v
219
229
 
230
+ functools.update_wrapper(wrapper, func)
220
231
  wrapper.cache_clear = lambda: cache.clear()
221
232
  return wrapper
222
233
 
@@ -225,6 +236,7 @@ def _uncached(func):
225
236
  def wrapper(*args, **kwargs):
226
237
  return func(*args, **kwargs)
227
238
 
239
+ functools.update_wrapper(wrapper, func)
228
240
  wrapper.cache_clear = lambda: None
229
241
  return wrapper
230
242
 
@@ -265,5 +277,4 @@ def _wrapper(func, cache, key, lock=None, cond=None, info=None):
265
277
  wrapper.cache_key = key
266
278
  wrapper.cache_lock = lock if lock is not None else cond
267
279
  wrapper.cache_condition = cond
268
-
269
- return functools.update_wrapper(wrapper, func)
280
+ return wrapper
@@ -5,24 +5,9 @@
5
5
  __all__ = ()
6
6
 
7
7
  import functools
8
- import warnings
9
8
  import weakref
10
9
 
11
-
12
- def _warn_classmethod(stacklevel):
13
- warnings.warn(
14
- "decorating class methods with @cachedmethod is deprecated",
15
- DeprecationWarning,
16
- stacklevel=stacklevel,
17
- )
18
-
19
-
20
- def _warn_instance_dict(msg, stacklevel):
21
- warnings.warn(
22
- msg,
23
- DeprecationWarning,
24
- stacklevel=stacklevel,
25
- )
10
+ from ._descriptor import _ClassMethodDescriptor, _MethodDescriptor, _warn_classmethod
26
11
 
27
12
 
28
13
  def _none(_):
@@ -65,82 +50,6 @@ class _WrapperBase:
65
50
  return self.__cond(self._obj)
66
51
 
67
52
 
68
- class _DescriptorBase:
69
- """Descriptor base class implementing the basic descriptor protocol."""
70
-
71
- def __init__(self, deprecated=False):
72
- self.__attrname = None
73
- self.__deprecated = deprecated
74
-
75
- def __set_name__(self, owner, name):
76
- if self.__attrname is None:
77
- self.__attrname = name
78
- elif name != self.__attrname:
79
- raise TypeError(
80
- "Cannot assign the same @cachedmethod to two different names "
81
- f"({self.__attrname!r} and {name!r})."
82
- )
83
-
84
- def __get__(self, obj, objtype=None):
85
- wrapper = self.Wrapper(obj) # type: ignore
86
- if obj is None:
87
- # Return the wrapper itself without modification when accessed
88
- # through the class to support class-level introspection, such
89
- # as for mocking with autospec=True in unittest.mock.
90
- pass
91
- elif self.__attrname is not None:
92
- # replace descriptor instance with wrapper in instance dict
93
- try:
94
- # In case of a race condition where another thread already replaced
95
- # the descriptor, prefer the initial wrapper.
96
- wrapper = obj.__dict__.setdefault(self.__attrname, wrapper)
97
- except AttributeError:
98
- # not all objects have __dict__ (e.g. class defines slots)
99
- msg = (
100
- f"No '__dict__' attribute on {type(obj).__name__!r} "
101
- f"instance to cache {self.__attrname!r} property."
102
- )
103
- if self.__deprecated:
104
- _warn_instance_dict(msg, 3)
105
- else:
106
- raise TypeError(msg) from None
107
- except TypeError:
108
- msg = (
109
- f"The '__dict__' attribute on {type(obj).__name__!r} "
110
- f"instance does not support item assignment for "
111
- f"caching {self.__attrname!r} property."
112
- )
113
- if self.__deprecated:
114
- _warn_instance_dict(msg, 3)
115
- else:
116
- raise TypeError(msg) from None
117
- elif self.__deprecated:
118
- pass # deprecated @classmethod, warning already raised elsewhere
119
- else:
120
- msg = "Cannot use @cachedmethod instance without calling __set_name__ on it"
121
- raise TypeError(msg) from None
122
- return wrapper
123
-
124
-
125
- class _DeprecatedDescriptorBase(_DescriptorBase):
126
- """Descriptor base class supporting deprecated @classmethod use."""
127
-
128
- def __init__(self, wrapper, cache_clear):
129
- super().__init__(deprecated=True)
130
- self.__wrapper = wrapper
131
- self.__cache_clear = cache_clear
132
-
133
- # called for @classmethod with Python >= 3.13
134
- def __call__(self, *args, **kwargs):
135
- _warn_classmethod(stacklevel=3)
136
- return self.__wrapper(*args, **kwargs)
137
-
138
- # backward-compatible @classmethod handling with Python >= 3.13
139
- def cache_clear(self, objtype):
140
- _warn_classmethod(stacklevel=3)
141
- return self.__cache_clear(objtype)
142
-
143
-
144
53
  # At least for now, the implementation prefers clarity and performance
145
54
  # over ease of maintenance, thus providing separate descriptors for
146
55
  # all valid combinations of decorator parameters lock, condition and
@@ -148,7 +57,7 @@ class _DeprecatedDescriptorBase(_DescriptorBase):
148
57
 
149
58
 
150
59
  def _condition_info(method, cache, key, lock, cond, info):
151
- class Descriptor(_DescriptorBase):
60
+ class Descriptor(_MethodDescriptor):
152
61
  class Wrapper(_WrapperBase):
153
62
  def __init__(self, obj):
154
63
  super().__init__(obj, method, cache, key, lock, cond)
@@ -196,7 +105,7 @@ def _condition_info(method, cache, key, lock, cond, info):
196
105
 
197
106
 
198
107
  def _locked_info(method, cache, key, lock, info):
199
- class Descriptor(_DescriptorBase):
108
+ class Descriptor(_MethodDescriptor):
200
109
  class Wrapper(_WrapperBase):
201
110
  def __init__(self, obj):
202
111
  super().__init__(obj, method, cache, key, lock)
@@ -236,7 +145,7 @@ def _locked_info(method, cache, key, lock, info):
236
145
 
237
146
 
238
147
  def _unlocked_info(method, cache, key, info):
239
- class Descriptor(_DescriptorBase):
148
+ class Descriptor(_MethodDescriptor):
240
149
  class Wrapper(_WrapperBase):
241
150
  def __init__(self, obj):
242
151
  super().__init__(obj, method, cache, key)
@@ -303,7 +212,7 @@ def _condition(method, cache, key, lock, cond):
303
212
  p = pending.setdefault(self, set())
304
213
  return wrapper(self, p, *args, **kwargs)
305
214
 
306
- class Descriptor(_DeprecatedDescriptorBase):
215
+ class Descriptor(_ClassMethodDescriptor):
307
216
  class Wrapper(_WrapperBase):
308
217
  def __init__(self, obj):
309
218
  super().__init__(obj, method, cache, key, lock, cond)
@@ -343,7 +252,7 @@ def _locked(method, cache, key, lock):
343
252
  with lock(self):
344
253
  c.clear()
345
254
 
346
- class Descriptor(_DeprecatedDescriptorBase):
255
+ class Descriptor(_ClassMethodDescriptor):
347
256
  class Wrapper(_WrapperBase):
348
257
  def __init__(self, obj):
349
258
  super().__init__(obj, method, cache, key, lock)
@@ -377,7 +286,7 @@ def _unlocked(method, cache, key):
377
286
  c = cache(self)
378
287
  c.clear()
379
288
 
380
- class Descriptor(_DeprecatedDescriptorBase):
289
+ class Descriptor(_ClassMethodDescriptor):
381
290
  class Wrapper(_WrapperBase):
382
291
  def __init__(self, obj):
383
292
  super().__init__(obj, method, cache, key)
@@ -0,0 +1,126 @@
1
+ """Descriptor helpers."""
2
+
3
+ __all__ = ()
4
+
5
+ import functools
6
+ import warnings
7
+
8
+
9
+ def _warn_classmethod(stacklevel):
10
+ warnings.warn(
11
+ "decorating class methods with @cachedmethod is deprecated",
12
+ DeprecationWarning,
13
+ stacklevel=stacklevel,
14
+ )
15
+
16
+
17
+ def _warn_instance_dict(msg, stacklevel):
18
+ warnings.warn(
19
+ msg,
20
+ DeprecationWarning,
21
+ stacklevel=stacklevel,
22
+ )
23
+
24
+
25
+ class _MethodDescriptor:
26
+ """Descriptor class implementing the basic descriptor protocol."""
27
+
28
+ def __init__(self, deprecated=False):
29
+ self.__attrname = None
30
+ self.__deprecated = deprecated
31
+ self.__unbound = None
32
+
33
+ def __set_name__(self, owner, name):
34
+ if self.__attrname is None:
35
+ self.__attrname = name
36
+ elif name != self.__attrname:
37
+ raise TypeError(
38
+ "Cannot assign the same @cachedmethod to two different names "
39
+ f"({self.__attrname!r} and {name!r})."
40
+ )
41
+
42
+ def __get__(self, obj, objtype=None):
43
+ if obj is None:
44
+ # objtype is not used for instance lookups, so it need not be bound here
45
+ return self.__get_unbound()
46
+ elif self.__attrname is not None:
47
+ # replace descriptor instance with wrapper in instance dict
48
+ wrapper = self.Wrapper(obj) # type: ignore
49
+ try:
50
+ descriptor = next(
51
+ (
52
+ cls.__dict__[self.__attrname]
53
+ for cls in type(obj).__mro__
54
+ if self.__attrname in cls.__dict__
55
+ ),
56
+ None,
57
+ )
58
+ if descriptor is self:
59
+ # In case of a race condition where another thread already replaced
60
+ # the descriptor, prefer the initial wrapper.
61
+ wrapper = obj.__dict__.setdefault(self.__attrname, wrapper)
62
+ else:
63
+ # A super() lookup must neither reuse the override's wrapper
64
+ # nor replace it, so preserve state separately for each descriptor.
65
+ wrappers = obj.__dict__.setdefault("__cachetools_cachedmethods", {})
66
+ wrapper = wrappers.setdefault(self, wrapper)
67
+ except AttributeError:
68
+ # not all objects have __dict__ (e.g. class defines slots)
69
+ msg = (
70
+ f"No '__dict__' attribute on {type(obj).__name__!r} "
71
+ f"instance to cache {self.__attrname!r} property."
72
+ )
73
+ if self.__deprecated:
74
+ _warn_instance_dict(msg, 3)
75
+ else:
76
+ raise TypeError(msg) from None
77
+ except TypeError:
78
+ msg = (
79
+ f"The '__dict__' attribute on {type(obj).__name__!r} "
80
+ f"instance does not support item assignment for "
81
+ f"caching {self.__attrname!r} property."
82
+ )
83
+ if self.__deprecated:
84
+ _warn_instance_dict(msg, 3)
85
+ else:
86
+ raise TypeError(msg) from None
87
+ elif self.__deprecated:
88
+ # deprecated @classmethod, warning already raised elsewhere
89
+ wrapper = self.Wrapper(obj) # type: ignore
90
+ else:
91
+ msg = "Cannot use @cachedmethod instance without calling __set_name__ on it"
92
+ raise TypeError(msg) from None
93
+ return wrapper
94
+
95
+ def __get_unbound(self):
96
+ # Created once and shared by all class accesses; objtype is not
97
+ # used for instance lookups, so it need not be bound here.
98
+ if self.__unbound is None:
99
+ descriptor = self
100
+
101
+ # As in the previous function wrapper, the receiver is named self.
102
+ @functools.wraps(self.__wrapped__) # type: ignore
103
+ def unbound(self, *args, **kwargs):
104
+ return descriptor.__get__(self)(*args, **kwargs)
105
+
106
+ self.__unbound = unbound # type: ignore
107
+ return self.__unbound
108
+
109
+
110
+ class _ClassMethodDescriptor(_MethodDescriptor):
111
+ """Descriptor base class supporting deprecated @classmethod use."""
112
+
113
+ def __init__(self, wrapper, cache_clear):
114
+ super().__init__(deprecated=True)
115
+ self.__wrapper = wrapper
116
+ self.__cache_clear = cache_clear
117
+
118
+ # called for @classmethod with Python >= 3.13
119
+ def __call__(self, *args, **kwargs):
120
+ _warn_classmethod(stacklevel=3)
121
+ return self.__wrapper(*args, **kwargs)
122
+
123
+ # backward-compatible @classmethod handling with Python >= 3.13
124
+ def cache_clear(self, objtype):
125
+ _warn_classmethod(stacklevel=3)
126
+ return self.__cache_clear(objtype)
@@ -1,19 +1,45 @@
1
1
  from collections.abc import Callable, Sequence
2
- from typing import Any, Final, Generic, ParamSpec, TypeVar, overload, type_check_only
2
+ from typing import (
3
+ Any,
4
+ Concatenate,
5
+ Final,
6
+ Generic,
7
+ ParamSpec,
8
+ TypeVar,
9
+ overload,
10
+ type_check_only,
11
+ )
3
12
 
4
13
  from . import _CacheInfo
5
14
 
6
15
  __all__: Final = ("fifo_cache", "lfu_cache", "lru_cache", "rr_cache", "ttl_cache")
7
16
 
8
17
  _P = ParamSpec("_P")
9
- _T = TypeVar("_T")
18
+ _Q = ParamSpec("_Q")
10
19
  _R = TypeVar("_R")
20
+ _S = TypeVar("_S")
21
+ _T = TypeVar("_T")
11
22
 
12
23
  @type_check_only
13
24
  class _cachetools_cache_wrapper(Generic[_P, _R]):
14
25
  __wrapped__: Callable[_P, _R]
15
26
  __name__: str
27
+ __qualname__: str
16
28
  __doc__: str | None
29
+ @overload # class access, e.g. Cls.method
30
+ def __get__(
31
+ self, obj: None, objtype: type | None = None
32
+ ) -> _cachetools_cache_wrapper[_P, _R]: ...
33
+ @overload # instance access binds the first parameter
34
+ def __get__(
35
+ self: _cachetools_cache_wrapper[Concatenate[_S, _Q], _R],
36
+ obj: _S,
37
+ objtype: type | None = None,
38
+ ) -> _cachetools_cache_wrapper[_Q, _R]: ...
39
+ @overload # fallback, e.g. when wrapped by @staticmethod or @classmethod
40
+ def __get__(
41
+ self, obj: object, objtype: type | None = None
42
+ ) -> _cachetools_cache_wrapper[_P, _R]: ...
17
43
  def __call__(self, /, *args: _P.args, **kwargs: _P.kwargs) -> _R: ...
18
44
  def cache_info(self) -> _CacheInfo: ...
19
45
  def cache_clear(self) -> None: ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachetools
3
- Version: 7.2.1
3
+ Version: 7.2.3
4
4
  Summary: Extensible memoizing collections and decorators
5
5
  Author-email: Thomas Kemmer <tkemmer@computer.org>
6
6
  Maintainer-email: Thomas Kemmer <tkemmer@computer.org>
@@ -10,6 +10,7 @@ src/cachetools/__init__.py
10
10
  src/cachetools/__init__.pyi
11
11
  src/cachetools/_cached.py
12
12
  src/cachetools/_cachedmethod.py
13
+ src/cachetools/_descriptor.py
13
14
  src/cachetools/func.py
14
15
  src/cachetools/func.pyi
15
16
  src/cachetools/keys.py
@@ -5,7 +5,7 @@ from typing import Any, Protocol
5
5
  import cachetools
6
6
 
7
7
 
8
- class _TestCaseProtocol(Protocol):
8
+ class SimplifiedTestCaseProtocol(Protocol):
9
9
  def assertEqual(self, first: Any, second: Any, msg: Any = None) -> None: ...
10
10
  def assertNotEqual(self, first: Any, second: Any, msg: Any = None) -> None: ...
11
11
  def assertTrue(self, expr: Any, msg: Any = None) -> None: ...
@@ -29,7 +29,7 @@ class _TestCaseProtocol(Protocol):
29
29
  ) -> Any: ...
30
30
 
31
31
 
32
- class CacheTestMixin(_TestCaseProtocol):
32
+ class CacheTestMixin(SimplifiedTestCaseProtocol):
33
33
  Cache: type[cachetools.Cache]
34
34
 
35
35
  def test_defaults(self):
@@ -4,10 +4,10 @@ import warnings
4
4
  import cachetools
5
5
  import cachetools.keys
6
6
 
7
- from . import CountedCondition, CountedLock, _TestCaseProtocol
7
+ from . import CountedCondition, CountedLock, SimplifiedTestCaseProtocol
8
8
 
9
9
 
10
- class DecoratorTestMixin(_TestCaseProtocol):
10
+ class DecoratorTestMixin(SimplifiedTestCaseProtocol):
11
11
  def cache(self, minsize):
12
12
  raise NotImplementedError
13
13
 
@@ -154,6 +154,8 @@ class DecoratorTestMixin(_TestCaseProtocol):
154
154
  wrapper = cachetools.cached(cache)(self.func)
155
155
 
156
156
  self.assertEqual(wrapper.__wrapped__, self.func)
157
+ self.assertEqual(wrapper.__name__, "func")
158
+ self.assertEqual(wrapper.__qualname__, "DecoratorTestMixin.func")
157
159
 
158
160
  self.assertEqual(len(cache), 0)
159
161
  self.assertEqual(wrapper.__wrapped__(0), 0)
@@ -316,6 +318,21 @@ class CacheWrapperTest(unittest.TestCase, DecoratorTestMixin):
316
318
  self.assertEqual(wrapper.cache_info(), (0, 0, 2, 0))
317
319
  self.assertEqual(lock.count, 13)
318
320
 
321
+ def test_stacked_decorators(self):
322
+ inner_cache = self.cache(2)
323
+ outer_cache = self.cache(2)
324
+ inner_wrapper = cachetools.cached(inner_cache, info=True)(self.func)
325
+ outer_wrapper = cachetools.cached(outer_cache, info=True)(inner_wrapper)
326
+ self.assertIs(outer_wrapper.__wrapped__, inner_wrapper)
327
+ self.assertIs(outer_wrapper.cache, outer_cache)
328
+ self.assertEqual(outer_wrapper(0), 0)
329
+ self.assertEqual(outer_wrapper(0), 0)
330
+ self.assertEqual(outer_wrapper.cache_info(), (1, 1, 2, 1))
331
+ self.assertEqual(inner_wrapper.cache_info(), (0, 1, 2, 1))
332
+ outer_wrapper.cache_clear()
333
+ self.assertEqual(len(outer_cache), 0)
334
+ self.assertEqual(len(inner_cache), 1)
335
+
319
336
  def test_zero_size_cache_decorator(self):
320
337
  cache = self.cache(0)
321
338
  wrapper = cachetools.cached(cache)(self.func)
@@ -380,6 +397,47 @@ class CacheWrapperTest(unittest.TestCase, DecoratorTestMixin):
380
397
  self.assertEqual(wrapper.cache_info(), (0, 1, 0, 0))
381
398
  self.assertEqual(lock.count, 5)
382
399
 
400
+ def test_decorator_method(self):
401
+ """Though it is not encouraged, like functools.lru_cache, the
402
+ @cached decorator may be used on methods, too.
403
+
404
+ """
405
+
406
+ cache = self.cache(2)
407
+
408
+ class Cached:
409
+ @cachetools.cached(cache, key=cachetools.keys.methodkey)
410
+ def get(self, value: int) -> int:
411
+ return value
412
+
413
+ @cachetools.cached(self.cache(2), key=cachetools.keys.methodkey, info=True)
414
+ def get_info(self, value):
415
+ return value
416
+
417
+ @staticmethod
418
+ @cachetools.cached(self.cache(2))
419
+ def get_static(value: int) -> int:
420
+ return value
421
+
422
+ cached = Cached()
423
+ self.assertEqual(cached.get(1), 1)
424
+ self.assertEqual(Cached.get(cached, 1), 1)
425
+ self.assertEqual(len(cache), 1)
426
+ cached.get.cache_clear()
427
+ self.assertEqual(len(cache), 0)
428
+
429
+ self.assertEqual(cached.get_info(1), 1)
430
+ self.assertEqual(cached.get_info(1), 1)
431
+ self.assertEqual(Cached.get_info(cached, 1), 1)
432
+ self.assertEqual(cached.get_info.cache_info(), (2, 1, 2, 1))
433
+ self.assertEqual(Cached.get_info.cache_info(), (2, 1, 2, 1))
434
+ cached.get_info.cache_clear()
435
+ self.assertEqual(cached.get_info.cache_info(), (0, 0, 2, 0))
436
+ self.assertEqual(Cached.get_info.cache_info(), (0, 0, 2, 0))
437
+
438
+ self.assertEqual(cached.get_static(1), 1)
439
+ self.assertEqual(Cached.get_static(1), 1)
440
+
383
441
 
384
442
  class DictWrapperTest(unittest.TestCase, DecoratorTestMixin):
385
443
  def cache(self, minsize):
@@ -1,10 +1,9 @@
1
1
  import unittest
2
- import unittest.mock
3
2
  import warnings
4
3
 
5
4
  from cachetools import Cache, cachedmethod, keys
6
5
 
7
- from . import CountedCondition, CountedLock, _TestCaseProtocol
6
+ from . import CountedCondition, CountedLock, SimplifiedTestCaseProtocol
8
7
 
9
8
 
10
9
  class Cached:
@@ -77,7 +76,7 @@ class Unhashable(Cached):
77
76
  raise TypeError("unhashable type")
78
77
 
79
78
 
80
- class MethodDecoratorTestMixin(_TestCaseProtocol):
79
+ class MethodDecoratorTestMixin(SimplifiedTestCaseProtocol):
81
80
  def cache(self, _minsize, **_kwargs):
82
81
  raise NotImplementedError
83
82
 
@@ -357,6 +356,7 @@ class MethodDecoratorTestMixin(_TestCaseProtocol):
357
356
  self.assertEqual(len(cache), 0)
358
357
  self.assertEqual(cached.get.__wrapped__(cached, 0), 0)
359
358
  self.assertEqual(cached.get.__name__, "get")
359
+ self.assertEqual(cached.get.__qualname__, "Cached.get")
360
360
  self.assertEqual((cached.get.__doc__ or "").strip(), "docstring")
361
361
  self.assertEqual(len(cache), 0)
362
362
  self.assertEqual(cached.get(0), 1)
@@ -632,6 +632,69 @@ class CacheMethodTest(unittest.TestCase, MethodDecoratorTestMixin):
632
632
  self.assertEqual(cached.get(2), 2)
633
633
  self.assertNotIn(2, cache.values())
634
634
 
635
+ def test_undecorated_override(self):
636
+ class Child(Cached):
637
+ def get_info(self, value): # type: ignore
638
+ return super().get_info(value) + 1
639
+
640
+ cache = self.cache(2)
641
+ cached = Child(cache)
642
+ self.assertEqual(cached.get_info(0), 1)
643
+ self.assertEqual(cached.get_info(0), 1)
644
+ self.assertEqual(super(Child, cached).get_info(0), 0)
645
+ self.assertEqual(cached.get_info(1), 2)
646
+ self.assertEqual(super(Child, cached).get_info(1), 1)
647
+ self.assertNotIn("get_info", vars(cached))
648
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (3, 2, 2, 2))
649
+
650
+ def test_decorated_override(self):
651
+ class Child(Cached):
652
+ def __init__(self, base_cache, child_cache):
653
+ super().__init__(base_cache)
654
+ self.child_cache = child_cache
655
+
656
+ @cachedmethod(lambda self: self.child_cache, info=True)
657
+ def get_info(self, value):
658
+ return super().get_info(value) + 1
659
+
660
+ cached = Child(self.cache(2), self.cache(2))
661
+ self.assertEqual(cached.get_info(0), 1)
662
+ self.assertEqual(cached.get_info(0), 1)
663
+ self.assertEqual(super(Child, cached).get_info(0), 0)
664
+ self.assertEqual(cached.get_info(1), 2)
665
+ self.assertEqual(super(Child, cached).get_info(1), 1)
666
+ self.assertIn("get_info", vars(cached))
667
+ self.assertEqual(cached.get_info.cache_info(), (1, 2, 2, 2))
668
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (2, 2, 2, 2))
669
+ cached.get_info.cache_clear()
670
+ self.assertEqual(cached.get_info.cache_info(), (0, 0, 2, 0))
671
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (2, 2, 2, 2))
672
+ self.assertEqual(cached.get_info(0), 1)
673
+ self.assertEqual(cached.get_info.cache_info(), (0, 1, 2, 1))
674
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (3, 2, 2, 2))
675
+ super(Child, cached).get_info.cache_clear()
676
+ self.assertEqual(cached.get_info.cache_info(), (0, 1, 2, 1))
677
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (0, 0, 2, 0))
678
+
679
+ def test_multilevel_override(self):
680
+ class Child(Cached):
681
+ @cachedmethod(lambda self: self.cache, key=keys.typedmethodkey)
682
+ def get_typed(self, value):
683
+ return super().get_typed(float(value)) + 1
684
+
685
+ class GrandChild(Child):
686
+ @cachedmethod(lambda self: self.cache, key=keys.typedmethodkey, info=True)
687
+ def get_typed(self, value):
688
+ return super().get_typed(int(value)) + 1
689
+
690
+ cache = self.cache(5) # shared cache, but different key types
691
+ cached = GrandChild(cache)
692
+ self.assertEqual(cached.get_typed("0"), 2)
693
+ self.assertEqual(cached.get_typed("0"), 2)
694
+ self.assertEqual(cached.get_typed.cache_info(), (1, 1, 5, 3))
695
+ self.assertEqual(super(GrandChild, cached).get_typed(0), 1)
696
+ self.assertEqual(super(Child, cached).get_typed(0.0), 0)
697
+
635
698
 
636
699
  class DictMethodTest(unittest.TestCase, MethodDecoratorTestMixin):
637
700
  def cache(self, minsize, **_kwargs):
@@ -683,8 +746,53 @@ class NoneMethodTest(unittest.TestCase):
683
746
  wrapper.cache_info()
684
747
 
685
748
 
686
- class AutospecTest(unittest.TestCase):
749
+ class UnboundMethodTest(unittest.TestCase):
750
+ def test_class_call(self):
751
+ cached = Cached(Cache(2))
752
+ self.assertEqual(Cached.get(cached, 1), 0)
753
+ self.assertEqual(cached.get(1), 0)
754
+ self.assertEqual(Cached.get(cached, 2), 1)
755
+ self.assertEqual(cached.get(2), 1)
756
+ self.assertEqual(cached.count, 2)
757
+ other = Cached(Cache(2), count=10)
758
+ self.assertEqual(Cached.get(other, 1), 10)
759
+ self.assertEqual(cached.get(1), 0)
760
+
761
+ def test_class_call_info(self):
762
+ cached = Cached(Cache(2))
763
+ self.assertEqual(Cached.get_info(cached, 1), 0)
764
+ self.assertEqual(cached.get_info(1), 0)
765
+ self.assertEqual(Cached.get_info(cached, 2), 1)
766
+ self.assertEqual(cached.get_info(2), 1)
767
+ self.assertEqual(cached.count, 2)
768
+ self.assertEqual(cached.get_info.cache_info(), (2, 2, 2, 2))
769
+ other = Cached(Cache(2), count=10)
770
+ self.assertEqual(Cached.get_info(other, 1), 10)
771
+ self.assertEqual(cached.get_info(1), 0)
772
+
773
+ def test_base_class_call(self):
774
+ cached = Unhashable(Cache(2))
775
+ self.assertEqual(Cached.get(cached, 1), 0)
776
+ self.assertEqual(cached.get(1), 0)
777
+ self.assertEqual(cached.count, 1)
778
+
779
+ def test_class_access_identity(self):
780
+ self.assertIs(Cached.get, Cached.get)
781
+ self.assertIs(Cached.get_info, Cached.get_info)
782
+ self.assertIs(Unhashable.get, Cached.get)
783
+ self.assertIsNot(Cached.get, Cached.get_info)
784
+
785
+ def test_wrapped_method(self):
786
+ cached = Cached(Cache(2))
787
+ self.assertIs(Cached.get.__wrapped__, cached.get.__wrapped__)
788
+ self.assertEqual(Cached.get.__name__, "get")
789
+ self.assertEqual(Cached.get.__qualname__, "Cached.get")
790
+ self.assertEqual(Cached.get.__wrapped__(cached, 1), 0)
791
+ self.assertEqual(Cached.get.__wrapped__(cached, 1), 1)
792
+ self.assertEqual(len(cached.cache), 0)
793
+
687
794
  def test_autospec_no_warnings(self):
795
+ import unittest.mock
688
796
 
689
797
  with warnings.catch_warnings(record=True) as w:
690
798
  warnings.simplefilter("always")
@@ -4,10 +4,10 @@ from typing import Any
4
4
 
5
5
  import cachetools.func
6
6
 
7
- from . import _TestCaseProtocol
7
+ from . import SimplifiedTestCaseProtocol
8
8
 
9
9
 
10
- class DecoratorTestMixin(_TestCaseProtocol):
10
+ class DecoratorTestMixin(SimplifiedTestCaseProtocol):
11
11
  DECORATOR: Callable[..., Any]
12
12
 
13
13
  def decorator(self, maxsize, **kwargs):
@@ -109,10 +109,77 @@ class DecoratorTestMixin(_TestCaseProtocol):
109
109
  # Then do a call which will cause a deadlock with a non-reentrant lock.
110
110
  self.assertEqual(cached(RecursiveEquals(True)), RecursiveEquals(True))
111
111
 
112
+ def test_decorator_method(self):
113
+ """Though it is not encouraged, like functools.lru_cache, the
114
+ cachetools.func decorators may be used on methods, too.
115
+
116
+ """
117
+
118
+ decorator = self.decorator(maxsize=2)
119
+
120
+ class Cached:
121
+ @decorator
122
+ def get(self, n):
123
+ return n
124
+
125
+ cached = Cached()
126
+ self.assertEqual(cached.get(1), 1)
127
+ self.assertEqual(cached.get(1), 1)
128
+ self.assertEqual(Cached.get(cached, 1), 1)
129
+ self.assertEqual(cached.get.cache_info(), (2, 1, 2, 1))
130
+ self.assertEqual(Cached.get.cache_info(), (2, 1, 2, 1))
131
+ cached.get.cache_clear()
132
+ self.assertEqual(cached.get.cache_info(), (0, 0, 2, 0))
133
+ self.assertEqual(Cached.get.cache_info(), (0, 0, 2, 0))
134
+
135
+ def test_stacked_decorators(self):
136
+ inner_wrapper = self.decorator(maxsize=2)(lambda n: n)
137
+ outer_wrapper = self.decorator(maxsize=3, typed=True)(inner_wrapper)
138
+ self.assertIs(outer_wrapper.__wrapped__, inner_wrapper)
139
+ self.assertEqual(
140
+ inner_wrapper.cache_parameters(), {"maxsize": 2, "typed": False}
141
+ )
142
+ self.assertEqual(
143
+ outer_wrapper.cache_parameters(), {"maxsize": 3, "typed": True}
144
+ )
145
+ self.assertEqual(outer_wrapper(1), 1)
146
+ self.assertEqual(outer_wrapper(1), 1)
147
+ self.assertEqual(outer_wrapper.cache_info(), (1, 1, 3, 1))
148
+ self.assertEqual(inner_wrapper.cache_info(), (0, 1, 2, 1))
149
+ outer_wrapper.cache_clear()
150
+ self.assertEqual(outer_wrapper.cache_info(), (0, 0, 3, 0))
151
+ self.assertEqual(inner_wrapper.cache_info(), (0, 1, 2, 1))
152
+
112
153
 
113
154
  class FIFODecoratorTest(unittest.TestCase, DecoratorTestMixin):
114
155
  DECORATOR = staticmethod(cachetools.func.fifo_cache)
115
156
 
157
+ def test_method_typed(self):
158
+ """Type-checked by pyright via the stub __get__ overloads."""
159
+
160
+ class Cached:
161
+ @cachetools.func.fifo_cache(maxsize=2)
162
+ def get(self, n: int) -> int:
163
+ return n
164
+
165
+ @cachetools.func.fifo_cache
166
+ def get_bare(self, n: int) -> int:
167
+ return n
168
+
169
+ @staticmethod
170
+ @cachetools.func.fifo_cache
171
+ def get_static(n: int) -> int:
172
+ return n
173
+
174
+ cached = Cached()
175
+ result: int = cached.get(1)
176
+ self.assertEqual(result, 1)
177
+ self.assertEqual(Cached.get(cached, 1), 1)
178
+ self.assertEqual(cached.get.cache_info(), (1, 1, 2, 1))
179
+ self.assertEqual(cached.get_bare(1), 1)
180
+ self.assertEqual(cached.get_static(1), 1)
181
+ self.assertEqual(Cached.get_static(1), 1)
182
+
116
183
 
117
184
  class LFUDecoratorTest(unittest.TestCase, DecoratorTestMixin):
118
185
  DECORATOR = staticmethod(cachetools.func.lfu_cache)
@@ -0,0 +1,103 @@
1
+ import unittest
2
+ from threading import Barrier, Condition, Lock, Thread
3
+
4
+ from cachetools import LRUCache, cached, cachedmethod
5
+
6
+
7
+ class ThreadingTest(unittest.TestCase):
8
+ NTHREADS = 10
9
+
10
+ TIMEOUT = 10
11
+
12
+ def test_cached_lock(self):
13
+ for info in (False, True):
14
+ with self.subTest(info=info):
15
+ barrier = Barrier(self.NTHREADS, timeout=self.TIMEOUT)
16
+ cache = LRUCache(1)
17
+
18
+ @cached(cache, lock=Lock(), info=info)
19
+ def func(barrier=barrier):
20
+ result = object()
21
+ barrier.wait()
22
+ return result
23
+
24
+ self.run_lock_test(func, cache, info)
25
+
26
+ def test_cachedmethod_lock(self):
27
+ for info in (False, True):
28
+ with self.subTest(info=info):
29
+ barrier = Barrier(self.NTHREADS, timeout=self.TIMEOUT)
30
+ cache = LRUCache(1)
31
+ lock = Lock()
32
+
33
+ class Cached:
34
+ @cachedmethod(lambda _: cache, lock=lambda _: lock, info=info)
35
+ def method(self, barrier=barrier):
36
+ result = object()
37
+ barrier.wait()
38
+ return result
39
+
40
+ self.run_lock_test(Cached().method, cache, info)
41
+
42
+ def test_cached_cond(self):
43
+ for info in (False, True):
44
+ with self.subTest(info=info):
45
+ cache = LRUCache(1)
46
+
47
+ @cached(cache, condition=Condition(), info=info)
48
+ def func():
49
+ result = object()
50
+ return result
51
+
52
+ self.run_cond_test(func, cache, info)
53
+
54
+ def test_cachedmethod_cond(self):
55
+ for info in (False, True):
56
+ with self.subTest(info=info):
57
+ cache = LRUCache(1)
58
+ cond = Condition()
59
+
60
+ class Cached:
61
+ @cachedmethod(lambda _: cache, condition=lambda _: cond, info=info)
62
+ def method(self):
63
+ result = object()
64
+ return result
65
+
66
+ self.run_cond_test(Cached().method, cache, info)
67
+
68
+ def run_lock_test(self, func, cache, info):
69
+ self.run_threading_test(func, cache)
70
+
71
+ if info:
72
+ cache_info = func.cache_info()
73
+ self.assertEqual(cache_info.hits + cache_info.misses, self.NTHREADS)
74
+ self.assertGreaterEqual(cache_info.misses, 1)
75
+
76
+ def run_cond_test(self, func, cache, info):
77
+ self.run_threading_test(func, cache)
78
+
79
+ if info:
80
+ cache_info = func.cache_info()
81
+ self.assertEqual(cache_info.hits, self.NTHREADS - 1)
82
+ self.assertEqual(cache_info.misses, 1)
83
+
84
+ def run_threading_test(self, func, cache):
85
+ results = [None] * self.NTHREADS
86
+
87
+ def run(index):
88
+ results[index] = func()
89
+
90
+ threads = [Thread(target=run, args=[i]) for i in range(self.NTHREADS)]
91
+ for thread in threads:
92
+ thread.start()
93
+ for thread in threads:
94
+ thread.join(timeout=self.TIMEOUT)
95
+ self.assertFalse(thread.is_alive())
96
+ self.assertEqual(len(cache), 1)
97
+
98
+ # multiple threads may compute the value, but the first one
99
+ # should always win and all concurrent invocations should
100
+ # return the same result
101
+ cached_result = next(iter(cache.values()))
102
+ for result in results:
103
+ self.assertIs(result, cached_result)
@@ -218,6 +218,22 @@ class TTLCacheTest(unittest.TestCase, CacheTestMixin):
218
218
  self.assertEqual([(1, 1)], list(items))
219
219
  self.assertEqual(0, len(cache))
220
220
 
221
+ def test_ttl_datetime_overflow(self):
222
+ from datetime import datetime, timedelta
223
+
224
+ cache = TTLCache[str, int, datetime](
225
+ maxsize=1, ttl=timedelta(days=1), timer=lambda: datetime.max
226
+ )
227
+
228
+ with self.assertRaises(OverflowError):
229
+ cache["overflow"] = 1
230
+
231
+ self.assertNotIn("overflow", cache)
232
+ self.assertEqual(0, len(cache))
233
+ self.assertEqual(0, cache.currsize)
234
+ self.assertEqual([], list(cache))
235
+ self.assertEqual([], cache.expire())
236
+
221
237
  def test_ttl_clear(self):
222
238
  cache = TTLCache[int, int, int](maxsize=2, ttl=2, timer=Timer())
223
239
 
@@ -1,122 +0,0 @@
1
- import threading
2
- import time
3
- import unittest
4
- from typing import Any
5
-
6
- from cachetools import LRUCache, cached, cachedmethod
7
-
8
- count = 0
9
-
10
-
11
- @cached(cache=LRUCache(1), condition=threading.Condition(), info=True)
12
- def func():
13
- global count
14
- time.sleep(1.0)
15
- count += 1
16
- return count
17
-
18
-
19
- @cached(cache=LRUCache(1), lock=threading.Lock(), info=True)
20
- def locked_func():
21
- global count
22
- time.sleep(1.0)
23
- count += 1
24
- return 42
25
-
26
-
27
- class ThreadingTest(unittest.TestCase):
28
- NTHREADS = 10
29
-
30
- TIMEOUT = 10
31
-
32
- cache: LRUCache[Any, int] = LRUCache(1)
33
-
34
- cond = threading.Condition()
35
-
36
- lock = threading.Lock()
37
-
38
- count = 0
39
-
40
- @cachedmethod(
41
- cache=lambda self: self.cache, condition=lambda self: self.cond, info=True
42
- )
43
- def method(self):
44
- time.sleep(1.0)
45
- self.count += 1
46
- return 42
47
-
48
- @cachedmethod(cache=lambda self: self.cache, lock=lambda self: self.lock, info=True)
49
- def locked_method(self):
50
- time.sleep(1.0)
51
- self.count += 1
52
- return 42
53
-
54
- def test_cached_stampede(self):
55
- global count
56
- count = 0
57
- threads = [threading.Thread(target=func) for i in range(self.NTHREADS)]
58
- for t in threads:
59
- t.start()
60
- for t in threads:
61
- t.join(timeout=self.TIMEOUT)
62
- self.assertFalse(t.is_alive())
63
-
64
- self.assertEqual(count, 1)
65
-
66
- info = func.cache_info()
67
- self.assertEqual(info.hits, self.NTHREADS - 1)
68
- self.assertEqual(info.misses, 1)
69
-
70
- def test_cachedmethod_stampede(self):
71
- self.cache = LRUCache(1)
72
- self.count = 0
73
- threads = [threading.Thread(target=self.method) for i in range(self.NTHREADS)]
74
- for t in threads:
75
- t.start()
76
- for t in threads:
77
- t.join(timeout=self.TIMEOUT)
78
- self.assertFalse(t.is_alive())
79
-
80
- self.assertEqual(self.count, 1)
81
-
82
- info = self.method.cache_info()
83
- self.assertEqual(info.hits, self.NTHREADS - 1)
84
- self.assertEqual(info.misses, 1)
85
-
86
- def test_cached_locked(self):
87
- global count
88
- count = 0
89
- threads = [threading.Thread(target=locked_func) for i in range(self.NTHREADS)]
90
- for t in threads:
91
- t.start()
92
- for t in threads:
93
- t.join(timeout=self.TIMEOUT)
94
- self.assertFalse(t.is_alive())
95
-
96
- # Without condition/stampede prevention, func is called by multiple
97
- # threads, but all return the same cached value (42).
98
- self.assertGreaterEqual(count, 1)
99
-
100
- info = locked_func.cache_info()
101
- self.assertEqual(info.hits + info.misses, self.NTHREADS)
102
- self.assertGreaterEqual(info.misses, 1)
103
-
104
- def test_cachedmethod_locked(self):
105
- self.cache = LRUCache(1)
106
- self.count = 0
107
- threads = [
108
- threading.Thread(target=self.locked_method) for i in range(self.NTHREADS)
109
- ]
110
- for t in threads:
111
- t.start()
112
- for t in threads:
113
- t.join(timeout=self.TIMEOUT)
114
- self.assertFalse(t.is_alive())
115
-
116
- # Multiple threads may compute the value, but setdefault ensures
117
- # the first cached result wins and all return 42.
118
- self.assertGreaterEqual(self.count, 1)
119
-
120
- info = self.locked_method.cache_info()
121
- self.assertEqual(info.hits + info.misses, self.NTHREADS)
122
- self.assertGreaterEqual(info.misses, 1)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes