cachetools 7.2.0__tar.gz → 7.2.2__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.0 → cachetools-7.2.2}/CHANGELOG.rst +26 -0
  2. {cachetools-7.2.0/src/cachetools.egg-info → cachetools-7.2.2}/PKG-INFO +1 -1
  3. {cachetools-7.2.0 → cachetools-7.2.2}/docs/index.rst +3 -2
  4. {cachetools-7.2.0 → cachetools-7.2.2}/pyproject.toml +0 -3
  5. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools/__init__.py +8 -8
  6. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools/__init__.pyi +21 -0
  7. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools/_cached.py +2 -0
  8. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools/_cachedmethod.py +9 -98
  9. cachetools-7.2.2/src/cachetools/_descriptor.py +126 -0
  10. {cachetools-7.2.0 → cachetools-7.2.2/src/cachetools.egg-info}/PKG-INFO +1 -1
  11. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools.egg-info/SOURCES.txt +1 -0
  12. {cachetools-7.2.0 → cachetools-7.2.2}/tests/__init__.py +2 -2
  13. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_cached.py +23 -2
  14. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_cachedmethod.py +112 -4
  15. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_func.py +25 -2
  16. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_rr.py +15 -0
  17. cachetools-7.2.2/tests/test_threading.py +103 -0
  18. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_ttl.py +16 -0
  19. cachetools-7.2.0/tests/test_threading.py +0 -122
  20. {cachetools-7.2.0 → cachetools-7.2.2}/LICENSE +0 -0
  21. {cachetools-7.2.0 → cachetools-7.2.2}/MANIFEST.in +0 -0
  22. {cachetools-7.2.0 → cachetools-7.2.2}/README.rst +0 -0
  23. {cachetools-7.2.0 → cachetools-7.2.2}/docs/conf.py +0 -0
  24. {cachetools-7.2.0 → cachetools-7.2.2}/setup.cfg +0 -0
  25. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools/func.py +0 -0
  26. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools/func.pyi +0 -0
  27. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools/keys.py +0 -0
  28. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools/keys.pyi +0 -0
  29. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools/py.typed +0 -0
  30. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools.egg-info/dependency_links.txt +0 -0
  31. {cachetools-7.2.0 → cachetools-7.2.2}/src/cachetools.egg-info/top_level.txt +0 -0
  32. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_cache.py +0 -0
  33. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_classmethod.py +0 -0
  34. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_fifo.py +0 -0
  35. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_keys.py +0 -0
  36. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_lfu.py +0 -0
  37. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_lru.py +0 -0
  38. {cachetools-7.2.0 → cachetools-7.2.2}/tests/test_tlru.py +0 -0
  39. {cachetools-7.2.0 → cachetools-7.2.2}/tox.ini +0 -0
@@ -1,3 +1,29 @@
1
+ v7.2.2 (2026-10-15)
2
+ ===================
3
+
4
+ - Fix ``@cachedmethod`` for subclasses that override a decorated
5
+ method and call the base class implementation via ``super()``.
6
+
7
+ - Restore class access to instance methods decorated with
8
+ ``@cachedmethod``.
9
+
10
+ - Improve ``TTLCache`` robustness when calculating expiration time
11
+ fails.
12
+
13
+ - Various unit test improvements.
14
+
15
+
16
+ v7.2.1 (2026-10-05)
17
+ ===================
18
+
19
+ - Improve error handling for ``RRCache.popitem()`` when the cache is
20
+ empty.
21
+
22
+ - Minor style and documentation improvements.
23
+
24
+ - Update CI environment.
25
+
26
+
1
27
  v7.2.0 (2026-09-16)
2
28
  ===================
3
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachetools
3
- Version: 7.2.0
3
+ Version: 7.2.2
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>
@@ -756,8 +756,9 @@ this module provides several memoizing function decorators with a
756
756
  similar API. All these decorators wrap a function with a memoizing
757
757
  callable that saves up to the `maxsize` most recent calls, using
758
758
  different caching strategies. If `maxsize` is set to :const:`None`,
759
- the caching strategy is effectively disabled and the cache can grow
760
- without bound.
759
+ the size limit is disabled and the cache can grow without bound.
760
+ For :func:`ttl_cache`, the time-to-live still applies, so cached
761
+ results expire after `ttl` even without a size limit.
761
762
 
762
763
  If the optional argument `typed` is set to :const:`True`, function
763
764
  arguments of different types will be cached separately. For example,
@@ -50,9 +50,6 @@ version = {attr = "cachetools.__version__"}
50
50
 
51
51
  [tool.pyright]
52
52
  typeCheckingMode = "standard"
53
- reportFunctionMemberAccess = "information"
54
- reportOptionalContextManager = "warning"
55
- reportOptionalMemberAccess = "warning"
56
53
 
57
54
  [tool.ruff.lint]
58
55
  ignore = ["DTZ005", "UP031"]
@@ -12,7 +12,7 @@ __all__ = (
12
12
  "cachedmethod",
13
13
  )
14
14
 
15
- __version__ = "7.2.0"
15
+ __version__ = "7.2.2"
16
16
 
17
17
  import collections
18
18
  import collections.abc
@@ -52,7 +52,7 @@ class Cache(collections.abc.MutableMapping):
52
52
  def __init__(self, maxsize, getsizeof=None):
53
53
  if maxsize < 0:
54
54
  raise ValueError("maxsize must be non-negative")
55
- if getsizeof:
55
+ if getsizeof is not None:
56
56
  self.getsizeof = getsizeof
57
57
  if self.getsizeof is not Cache.getsizeof:
58
58
  self.__size = {}
@@ -357,12 +357,11 @@ class RRCache(Cache):
357
357
 
358
358
  def popitem(self):
359
359
  """Remove and return a random `(key, value)` pair."""
360
- try:
361
- key = self.__choice(self.__keys)
362
- except IndexError:
360
+ keys = self.__keys
361
+ if not keys:
363
362
  raise KeyError("%s is empty" % type(self).__name__) from None
364
- else:
365
- return (key, self.pop(key))
363
+ key = self.__choice(keys)
364
+ return (key, self.pop(key))
366
365
 
367
366
  def clear(self):
368
367
  Cache.clear(self)
@@ -496,6 +495,7 @@ class TTLCache(_TimedCache):
496
495
 
497
496
  def __setitem__(self, key, value, cache_setitem=Cache.__setitem__):
498
497
  with self.timer as time:
498
+ expires = time + self.__ttl
499
499
  self.expire(time)
500
500
  cache_setitem(self, key, value)
501
501
  try:
@@ -504,7 +504,7 @@ class TTLCache(_TimedCache):
504
504
  self.__links[key] = link = TTLCache._Link(key)
505
505
  else:
506
506
  link.unlink()
507
- link.expires = time + self.__ttl
507
+ link.expires = expires
508
508
  link.next = root = self.__root
509
509
  link.prev = prev = root.prev
510
510
  prev.next = root.prev = link
@@ -191,6 +191,17 @@ def cached(
191
191
  info: Literal[True],
192
192
  ) -> Callable[[Callable[_P, _R]], _cached_wrapper_info[_P, _R]]: ...
193
193
 
194
+ @type_check_only
195
+ class _cachedmethod_unbound(Generic[_P, _R]):
196
+ __wrapped__: Callable[Concatenate[Any, _P], _R]
197
+ __name__: str
198
+ __doc__: str | None
199
+ @overload
200
+ def __call__(self, obj: Any, /, *args: _P.args, **kwargs: _P.kwargs) -> _R: ...
201
+ # deprecated @classmethod use
202
+ @overload
203
+ def __call__(self, /, *args: _P.args, **kwargs: _P.kwargs) -> _R: ...
204
+
194
205
  @type_check_only
195
206
  class _cachedmethod_wrapper(Generic[_P, _R]):
196
207
  __wrapped__: Callable[Concatenate[Any, _P], _R]
@@ -201,6 +212,11 @@ class _cachedmethod_wrapper(Generic[_P, _R]):
201
212
  cache_lock: AbstractContextManager[Any] | None = None
202
213
  cache_condition: _AbstractCondition | None = None
203
214
  def __set_name__(self, owner: type, name: str) -> None: ...
215
+ @overload
216
+ def __get__(
217
+ self, obj: None, objtype: type | None = None
218
+ ) -> _cachedmethod_unbound[_P, _R]: ...
219
+ @overload
204
220
  def __get__(
205
221
  self, obj: Any, objtype: type | None = None
206
222
  ) -> _cachedmethod_wrapper[_P, _R]: ...
@@ -209,6 +225,11 @@ class _cachedmethod_wrapper(Generic[_P, _R]):
209
225
 
210
226
  @type_check_only
211
227
  class _cachedmethod_wrapper_info(_cachedmethod_wrapper[_P, _R]):
228
+ @overload
229
+ def __get__(
230
+ self, obj: None, objtype: type | None = None
231
+ ) -> _cachedmethod_unbound[_P, _R]: ...
232
+ @overload
212
233
  def __get__(
213
234
  self, obj: Any, objtype: type | None = None
214
235
  ) -> _cachedmethod_wrapper_info[_P, _R]: ...
@@ -1,5 +1,7 @@
1
1
  """Function decorator helpers."""
2
2
 
3
+ # pyright: reportFunctionMemberAccess=false
4
+
3
5
  __all__ = ()
4
6
 
5
7
  import functools
@@ -1,26 +1,13 @@
1
1
  """Method decorator helpers."""
2
2
 
3
+ # pyright: reportOptionalContextManager=false, reportOptionalMemberAccess=false
4
+
3
5
  __all__ = ()
4
6
 
5
7
  import functools
6
- import warnings
7
8
  import weakref
8
9
 
9
-
10
- def _warn_classmethod(stacklevel):
11
- warnings.warn(
12
- "decorating class methods with @cachedmethod is deprecated",
13
- DeprecationWarning,
14
- stacklevel=stacklevel,
15
- )
16
-
17
-
18
- def _warn_instance_dict(msg, stacklevel):
19
- warnings.warn(
20
- msg,
21
- DeprecationWarning,
22
- stacklevel=stacklevel,
23
- )
10
+ from ._descriptor import _ClassMethodDescriptor, _MethodDescriptor, _warn_classmethod
24
11
 
25
12
 
26
13
  def _none(_):
@@ -63,82 +50,6 @@ class _WrapperBase:
63
50
  return self.__cond(self._obj)
64
51
 
65
52
 
66
- class _DescriptorBase:
67
- """Descriptor base class implementing the basic descriptor protocol."""
68
-
69
- def __init__(self, deprecated=False):
70
- self.__attrname = None
71
- self.__deprecated = deprecated
72
-
73
- def __set_name__(self, owner, name):
74
- if self.__attrname is None:
75
- self.__attrname = name
76
- elif name != self.__attrname:
77
- raise TypeError(
78
- "Cannot assign the same @cachedmethod to two different names "
79
- f"({self.__attrname!r} and {name!r})."
80
- )
81
-
82
- def __get__(self, obj, objtype=None):
83
- wrapper = self.Wrapper(obj) # type: ignore
84
- if obj is None:
85
- # Return the wrapper itself without modification when accessed
86
- # through the class to support class-level introspection, such
87
- # as for mocking with autospec=True in unittest.mock.
88
- pass
89
- elif self.__attrname is not None:
90
- # replace descriptor instance with wrapper in instance dict
91
- try:
92
- # In case of a race condition where another thread already replaced
93
- # the descriptor, prefer the initial wrapper.
94
- wrapper = obj.__dict__.setdefault(self.__attrname, wrapper)
95
- except AttributeError:
96
- # not all objects have __dict__ (e.g. class defines slots)
97
- msg = (
98
- f"No '__dict__' attribute on {type(obj).__name__!r} "
99
- f"instance to cache {self.__attrname!r} property."
100
- )
101
- if self.__deprecated:
102
- _warn_instance_dict(msg, 3)
103
- else:
104
- raise TypeError(msg) from None
105
- except TypeError:
106
- msg = (
107
- f"The '__dict__' attribute on {type(obj).__name__!r} "
108
- f"instance does not support item assignment for "
109
- f"caching {self.__attrname!r} property."
110
- )
111
- if self.__deprecated:
112
- _warn_instance_dict(msg, 3)
113
- else:
114
- raise TypeError(msg) from None
115
- elif self.__deprecated:
116
- pass # deprecated @classmethod, warning already raised elsewhere
117
- else:
118
- msg = "Cannot use @cachedmethod instance without calling __set_name__ on it"
119
- raise TypeError(msg) from None
120
- return wrapper
121
-
122
-
123
- class _DeprecatedDescriptorBase(_DescriptorBase):
124
- """Descriptor base class supporting deprecated @classmethod use."""
125
-
126
- def __init__(self, wrapper, cache_clear):
127
- super().__init__(deprecated=True)
128
- self.__wrapper = wrapper
129
- self.__cache_clear = cache_clear
130
-
131
- # called for @classmethod with Python >= 3.13
132
- def __call__(self, *args, **kwargs):
133
- _warn_classmethod(stacklevel=3)
134
- return self.__wrapper(*args, **kwargs)
135
-
136
- # backward-compatible @classmethod handling with Python >= 3.13
137
- def cache_clear(self, objtype):
138
- _warn_classmethod(stacklevel=3)
139
- return self.__cache_clear(objtype)
140
-
141
-
142
53
  # At least for now, the implementation prefers clarity and performance
143
54
  # over ease of maintenance, thus providing separate descriptors for
144
55
  # all valid combinations of decorator parameters lock, condition and
@@ -146,7 +57,7 @@ class _DeprecatedDescriptorBase(_DescriptorBase):
146
57
 
147
58
 
148
59
  def _condition_info(method, cache, key, lock, cond, info):
149
- class Descriptor(_DescriptorBase):
60
+ class Descriptor(_MethodDescriptor):
150
61
  class Wrapper(_WrapperBase):
151
62
  def __init__(self, obj):
152
63
  super().__init__(obj, method, cache, key, lock, cond)
@@ -194,7 +105,7 @@ def _condition_info(method, cache, key, lock, cond, info):
194
105
 
195
106
 
196
107
  def _locked_info(method, cache, key, lock, info):
197
- class Descriptor(_DescriptorBase):
108
+ class Descriptor(_MethodDescriptor):
198
109
  class Wrapper(_WrapperBase):
199
110
  def __init__(self, obj):
200
111
  super().__init__(obj, method, cache, key, lock)
@@ -234,7 +145,7 @@ def _locked_info(method, cache, key, lock, info):
234
145
 
235
146
 
236
147
  def _unlocked_info(method, cache, key, info):
237
- class Descriptor(_DescriptorBase):
148
+ class Descriptor(_MethodDescriptor):
238
149
  class Wrapper(_WrapperBase):
239
150
  def __init__(self, obj):
240
151
  super().__init__(obj, method, cache, key)
@@ -301,7 +212,7 @@ def _condition(method, cache, key, lock, cond):
301
212
  p = pending.setdefault(self, set())
302
213
  return wrapper(self, p, *args, **kwargs)
303
214
 
304
- class Descriptor(_DeprecatedDescriptorBase):
215
+ class Descriptor(_ClassMethodDescriptor):
305
216
  class Wrapper(_WrapperBase):
306
217
  def __init__(self, obj):
307
218
  super().__init__(obj, method, cache, key, lock, cond)
@@ -341,7 +252,7 @@ def _locked(method, cache, key, lock):
341
252
  with lock(self):
342
253
  c.clear()
343
254
 
344
- class Descriptor(_DeprecatedDescriptorBase):
255
+ class Descriptor(_ClassMethodDescriptor):
345
256
  class Wrapper(_WrapperBase):
346
257
  def __init__(self, obj):
347
258
  super().__init__(obj, method, cache, key, lock)
@@ -375,7 +286,7 @@ def _unlocked(method, cache, key):
375
286
  c = cache(self)
376
287
  c.clear()
377
288
 
378
- class Descriptor(_DeprecatedDescriptorBase):
289
+ class Descriptor(_ClassMethodDescriptor):
379
290
  class Wrapper(_WrapperBase):
380
291
  def __init__(self, obj):
381
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachetools
3
- Version: 7.2.0
3
+ Version: 7.2.2
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
 
@@ -380,6 +380,27 @@ class CacheWrapperTest(unittest.TestCase, DecoratorTestMixin):
380
380
  self.assertEqual(wrapper.cache_info(), (0, 1, 0, 0))
381
381
  self.assertEqual(lock.count, 5)
382
382
 
383
+ def test_decorator_method(self):
384
+ """Though it is not encouraged, like functools.lru_cache, the
385
+ @cached decorator may be used on methods, too.
386
+
387
+ """
388
+
389
+ class Cached:
390
+ @cachetools.cached(self.cache(2), key=cachetools.keys.methodkey, info=True)
391
+ def get(self, value):
392
+ return value
393
+
394
+ cached = Cached()
395
+ self.assertEqual(cached.get(1), 1) # type: ignore
396
+ self.assertEqual(cached.get(1), 1) # type: ignore
397
+ self.assertEqual(Cached.get(cached, 1), 1)
398
+ self.assertEqual(cached.get.cache_info(), (2, 1, 2, 1))
399
+ self.assertEqual(Cached.get.cache_info(), (2, 1, 2, 1))
400
+ cached.get.cache_clear()
401
+ self.assertEqual(cached.get.cache_info(), (0, 0, 2, 0))
402
+ self.assertEqual(Cached.get.cache_info(), (0, 0, 2, 0))
403
+
383
404
 
384
405
  class DictWrapperTest(unittest.TestCase, DecoratorTestMixin):
385
406
  def cache(self, minsize):
@@ -1,10 +1,12 @@
1
+ # TODO: type stub overloads for unbound methods?
2
+ # pyright: reportCallIssue=false
3
+
1
4
  import unittest
2
- import unittest.mock
3
5
  import warnings
4
6
 
5
7
  from cachetools import Cache, cachedmethod, keys
6
8
 
7
- from . import CountedCondition, CountedLock, _TestCaseProtocol
9
+ from . import CountedCondition, CountedLock, SimplifiedTestCaseProtocol
8
10
 
9
11
 
10
12
  class Cached:
@@ -77,7 +79,7 @@ class Unhashable(Cached):
77
79
  raise TypeError("unhashable type")
78
80
 
79
81
 
80
- class MethodDecoratorTestMixin(_TestCaseProtocol):
82
+ class MethodDecoratorTestMixin(SimplifiedTestCaseProtocol):
81
83
  def cache(self, _minsize, **_kwargs):
82
84
  raise NotImplementedError
83
85
 
@@ -632,6 +634,69 @@ class CacheMethodTest(unittest.TestCase, MethodDecoratorTestMixin):
632
634
  self.assertEqual(cached.get(2), 2)
633
635
  self.assertNotIn(2, cache.values())
634
636
 
637
+ def test_undecorated_override(self):
638
+ class Child(Cached):
639
+ def get_info(self, value): # type: ignore[reportIncompatibleVariableOverride]
640
+ return super().get_info(value) + 1
641
+
642
+ cache = self.cache(2)
643
+ cached = Child(cache)
644
+ self.assertEqual(cached.get_info(0), 1)
645
+ self.assertEqual(cached.get_info(0), 1)
646
+ self.assertEqual(super(Child, cached).get_info(0), 0)
647
+ self.assertEqual(cached.get_info(1), 2)
648
+ self.assertEqual(super(Child, cached).get_info(1), 1)
649
+ self.assertNotIn("get_info", vars(cached))
650
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (3, 2, 2, 2))
651
+
652
+ def test_decorated_override(self):
653
+ class Child(Cached):
654
+ def __init__(self, base_cache, child_cache):
655
+ super().__init__(base_cache)
656
+ self.child_cache = child_cache
657
+
658
+ @cachedmethod(lambda self: self.child_cache, info=True)
659
+ def get_info(self, value):
660
+ return super().get_info(value) + 1
661
+
662
+ cached = Child(self.cache(2), self.cache(2))
663
+ self.assertEqual(cached.get_info(0), 1)
664
+ self.assertEqual(cached.get_info(0), 1)
665
+ self.assertEqual(super(Child, cached).get_info(0), 0)
666
+ self.assertEqual(cached.get_info(1), 2)
667
+ self.assertEqual(super(Child, cached).get_info(1), 1)
668
+ self.assertIn("get_info", vars(cached))
669
+ self.assertEqual(cached.get_info.cache_info(), (1, 2, 2, 2))
670
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (2, 2, 2, 2))
671
+ cached.get_info.cache_clear()
672
+ self.assertEqual(cached.get_info.cache_info(), (0, 0, 2, 0))
673
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (2, 2, 2, 2))
674
+ self.assertEqual(cached.get_info(0), 1)
675
+ self.assertEqual(cached.get_info.cache_info(), (0, 1, 2, 1))
676
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (3, 2, 2, 2))
677
+ super(Child, cached).get_info.cache_clear()
678
+ self.assertEqual(cached.get_info.cache_info(), (0, 1, 2, 1))
679
+ self.assertEqual(super(Child, cached).get_info.cache_info(), (0, 0, 2, 0))
680
+
681
+ def test_multilevel_override(self):
682
+ class Child(Cached):
683
+ @cachedmethod(lambda self: self.cache, key=keys.typedmethodkey)
684
+ def get_typed(self, value):
685
+ return super().get_typed(float(value)) + 1
686
+
687
+ class GrandChild(Child):
688
+ @cachedmethod(lambda self: self.cache, key=keys.typedmethodkey, info=True)
689
+ def get_typed(self, value):
690
+ return super().get_typed(int(value)) + 1
691
+
692
+ cache = self.cache(5) # shared cache, but different key types
693
+ cached = GrandChild(cache)
694
+ self.assertEqual(cached.get_typed("0"), 2)
695
+ self.assertEqual(cached.get_typed("0"), 2)
696
+ self.assertEqual(cached.get_typed.cache_info(), (1, 1, 5, 3))
697
+ self.assertEqual(super(GrandChild, cached).get_typed(0), 1)
698
+ self.assertEqual(super(Child, cached).get_typed(0.0), 0)
699
+
635
700
 
636
701
  class DictMethodTest(unittest.TestCase, MethodDecoratorTestMixin):
637
702
  def cache(self, minsize, **_kwargs):
@@ -683,8 +748,51 @@ class NoneMethodTest(unittest.TestCase):
683
748
  wrapper.cache_info()
684
749
 
685
750
 
686
- class AutospecTest(unittest.TestCase):
751
+ class UnboundMethodTest(unittest.TestCase):
752
+ def test_class_call(self):
753
+ cached = Cached(Cache(2))
754
+ self.assertEqual(Cached.get(cached, 1), 0)
755
+ self.assertEqual(cached.get(1), 0)
756
+ self.assertEqual(Cached.get(cached, 2), 1)
757
+ self.assertEqual(cached.get(2), 1)
758
+ self.assertEqual(cached.count, 2)
759
+ other = Cached(Cache(2), count=10)
760
+ self.assertEqual(Cached.get(other, 1), 10)
761
+ self.assertEqual(cached.get(1), 0)
762
+
763
+ def test_class_call_info(self):
764
+ cached = Cached(Cache(2))
765
+ self.assertEqual(Cached.get_info(cached, 1), 0)
766
+ self.assertEqual(cached.get_info(1), 0)
767
+ self.assertEqual(Cached.get_info(cached, 2), 1)
768
+ self.assertEqual(cached.get_info(2), 1)
769
+ self.assertEqual(cached.count, 2)
770
+ self.assertEqual(cached.get_info.cache_info(), (2, 2, 2, 2))
771
+ other = Cached(Cache(2), count=10)
772
+ self.assertEqual(Cached.get_info(other, 1), 10)
773
+ self.assertEqual(cached.get_info(1), 0)
774
+
775
+ def test_base_class_call(self):
776
+ cached = Unhashable(Cache(2))
777
+ self.assertEqual(Cached.get(cached, 1), 0)
778
+ self.assertEqual(cached.get(1), 0)
779
+ self.assertEqual(cached.count, 1)
780
+
781
+ def test_class_access_identity(self):
782
+ self.assertIs(Cached.get, Cached.get)
783
+ self.assertIs(Cached.get_info, Cached.get_info)
784
+ self.assertIs(Unhashable.get, Cached.get)
785
+ self.assertIsNot(Cached.get, Cached.get_info)
786
+
787
+ def test_wrapped_method(self):
788
+ cached = Cached(Cache(2))
789
+ self.assertIs(Cached.get.__wrapped__, cached.get.__wrapped__)
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,6 +109,29 @@ 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
+
112
135
 
113
136
  class FIFODecoratorTest(unittest.TestCase, DecoratorTestMixin):
114
137
  DECORATOR = staticmethod(cachetools.func.fifo_cache)
@@ -86,3 +86,18 @@ class RRCacheTest(unittest.TestCase, CacheTestMixin):
86
86
  def test_rr_default_choice(self):
87
87
  cache = RRCache[int, int](maxsize=2)
88
88
  self.assertIs(cache.choice, random.choice)
89
+
90
+ def test_rr_popitem_empty_custom_choice(self):
91
+ # a custom `choice` function only has to handle non-empty
92
+ # sequences, so an empty cache has to be detected beforehand
93
+ for choice in (min, max, lambda seq: next(iter(seq))):
94
+ cache = RRCache[int, int](maxsize=2, choice=choice)
95
+
96
+ with self.assertRaises(KeyError):
97
+ cache.popitem()
98
+
99
+ cache[1] = 1
100
+ self.assertEqual((1, 1), cache.popitem())
101
+
102
+ with self.assertRaises(KeyError):
103
+ cache.popitem()
@@ -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