mcrotk-cache-utils 0.1.0__py3-none-any.whl
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.
- mcrotk_cache_utils/__init__.py +36 -0
- mcrotk_cache_utils/copying/__init__.py +1 -0
- mcrotk_cache_utils/copying/copy_handling.py +231 -0
- mcrotk_cache_utils/copying/instance_copying.py +54 -0
- mcrotk_cache_utils/decorators/__init__.py +1 -0
- mcrotk_cache_utils/decorators/cache_controls.py +82 -0
- mcrotk_cache_utils/decorators/function_caching.py +94 -0
- mcrotk_cache_utils/decorators/method_caching.py +204 -0
- mcrotk_cache_utils/decorators/rng_caching.py +194 -0
- mcrotk_cache_utils/decorators/support/__init__.py +1 -0
- mcrotk_cache_utils/decorators/support/decorator_options.py +55 -0
- mcrotk_cache_utils/decorators/support/placement_checks.py +61 -0
- mcrotk_cache_utils/keys/__init__.py +1 -0
- mcrotk_cache_utils/keys/cache_keys.py +217 -0
- mcrotk_cache_utils/keys/converters/__init__.py +12 -0
- mcrotk_cache_utils/keys/converters/arrays.py +47 -0
- mcrotk_cache_utils/keys/converters/containers.py +45 -0
- mcrotk_cache_utils/keys/converters/pandas_objects.py +134 -0
- mcrotk_cache_utils/keys/passthrough.py +169 -0
- mcrotk_cache_utils/py.typed +0 -0
- mcrotk_cache_utils-0.1.0.dist-info/METADATA +190 -0
- mcrotk_cache_utils-0.1.0.dist-info/RECORD +24 -0
- mcrotk_cache_utils-0.1.0.dist-info/WHEEL +4 -0
- mcrotk_cache_utils-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Caching decorators for methods and functions, built on cachetools.
|
|
2
|
+
|
|
3
|
+
Per-instance caches for methods, argument omission, passthrough (e.g. for RNGs), key converters, and
|
|
4
|
+
key helpers for arrays, containers and pandas objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .copying.instance_copying import CacheCopyWarning, copy_instance
|
|
8
|
+
from .decorators.cache_controls import CacheControls, cache_controls, clear_caches
|
|
9
|
+
from .decorators.function_caching import cached_function
|
|
10
|
+
from .decorators.method_caching import cached_method
|
|
11
|
+
from .decorators.rng_caching import (
|
|
12
|
+
rng_omit_cached_function,
|
|
13
|
+
rng_omit_cached_method,
|
|
14
|
+
rng_passthrough_cached_function,
|
|
15
|
+
rng_passthrough_cached_method,
|
|
16
|
+
)
|
|
17
|
+
from .keys.converters import array_key, container_key, dataframe_key, index_key, series_key
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CacheControls",
|
|
21
|
+
"CacheCopyWarning",
|
|
22
|
+
"array_key",
|
|
23
|
+
"cache_controls",
|
|
24
|
+
"cached_function",
|
|
25
|
+
"cached_method",
|
|
26
|
+
"clear_caches",
|
|
27
|
+
"container_key",
|
|
28
|
+
"copy_instance",
|
|
29
|
+
"dataframe_key",
|
|
30
|
+
"index_key",
|
|
31
|
+
"rng_omit_cached_function",
|
|
32
|
+
"rng_omit_cached_method",
|
|
33
|
+
"rng_passthrough_cached_function",
|
|
34
|
+
"rng_passthrough_cached_method",
|
|
35
|
+
"series_key",
|
|
36
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Copying instances with cached methods: ``copy_instance``, and the copy handling ``cached_method`` installs."""
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Copy handling for instances with cached methods: the ``__copy__``/``__deepcopy__`` ``cached_method`` installs.
|
|
2
|
+
|
|
3
|
+
Python's default copy would duplicate the instance ``__dict__``, which holds our caches and
|
|
4
|
+
cachetools' bound wrappers (bound to the original instance). The installed methods do the
|
|
5
|
+
default copy (honoring ``__getstate__``, ``__reduce__``, ``__slots__``), but first drop the
|
|
6
|
+
wrappers (recreated for the copy on first use) and empty or copy each cache according to its
|
|
7
|
+
method's ``on_copy``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import warnings
|
|
14
|
+
import weakref
|
|
15
|
+
from collections.abc import Iterator, MutableMapping
|
|
16
|
+
from typing import Any, Literal, NamedTuple
|
|
17
|
+
|
|
18
|
+
import cachetools
|
|
19
|
+
|
|
20
|
+
OnCopy = Literal["empty", "copy", "manual"]
|
|
21
|
+
ON_COPY_VALUES = ("empty", "copy", "manual")
|
|
22
|
+
|
|
23
|
+
CACHE_ATTR = "_mcrotk_cache_attr" # on our descriptors: the name of the instance attribute holding the cache
|
|
24
|
+
ON_COPY = "_mcrotk_on_copy" # on our descriptors: (on_copy value, whether it was given explicitly)
|
|
25
|
+
_OURS = "_mcrotk_copy" # on the __copy__/__deepcopy__ functions we install
|
|
26
|
+
|
|
27
|
+
_WRAPPERS = "__cachetools_cachedmethods" # cachetools' per-instance wrappers for super() lookups
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CacheCopyWarning(UserWarning):
|
|
31
|
+
"""A class with cached methods defines its own ``__copy__`` or ``__deepcopy__``.
|
|
32
|
+
|
|
33
|
+
Then ``cached_method`` can't install its copy handling, and whether copies are correct
|
|
34
|
+
depends on that method (:func:`copy_instance` can do the copying inside it). Silence with
|
|
35
|
+
``on_copy='manual'`` on the cached methods, or with
|
|
36
|
+
``warnings.filterwarnings('ignore', category=CacheCopyWarning)``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CachedMethodInfo(NamedTuple):
|
|
41
|
+
"""A cached method found in a class, with what copying needs to know about it."""
|
|
42
|
+
|
|
43
|
+
name: str # the method's name in its class (also the key of its cachetools wrapper in instances)
|
|
44
|
+
cache_attr: str
|
|
45
|
+
on_copy: str
|
|
46
|
+
explicit: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cached_methods(cls: type) -> Iterator[tuple[type, CachedMethodInfo]]:
|
|
50
|
+
"""Our cached methods in ``cls`` and its bases, with the class each is defined in."""
|
|
51
|
+
for klass in cls.__mro__:
|
|
52
|
+
for name, value in vars(klass).items():
|
|
53
|
+
attr = getattr(value, CACHE_ATTR, None)
|
|
54
|
+
if isinstance(attr, str):
|
|
55
|
+
on_copy, explicit = getattr(value, ON_COPY, ("empty", False))
|
|
56
|
+
yield klass, CachedMethodInfo(name, attr, on_copy, explicit)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- Installing at class creation ---
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InstallsCopyMethods:
|
|
63
|
+
"""Mixin for ``cached_method``'s descriptor: copy handling for the class, set up when the class is created."""
|
|
64
|
+
|
|
65
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
66
|
+
super().__set_name__(owner, name) # pyright: ignore[reportAttributeAccessIssue] (cachetools' descriptor)
|
|
67
|
+
on_copy, explicit = getattr(self, ON_COPY)
|
|
68
|
+
theirs = user_copy_methods(owner)
|
|
69
|
+
_check_on_copy(owner, name, on_copy, explicit, theirs)
|
|
70
|
+
if on_copy != "manual":
|
|
71
|
+
_install_copy_methods(owner, theirs)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_on_copy(owner: type, name: str, on_copy: str, explicit: bool, theirs: list[str]) -> None:
|
|
75
|
+
"""Errors for an ``on_copy`` that conflicts with the class's own copy methods, or the warning for the default."""
|
|
76
|
+
where = f"{owner.__qualname__}.{name}"
|
|
77
|
+
if on_copy == "manual":
|
|
78
|
+
if "__copy__" not in theirs:
|
|
79
|
+
raise TypeError(
|
|
80
|
+
f"{where}: on_copy='manual', but {owner.__qualname__} has no __copy__ of its own, so copy.copy "
|
|
81
|
+
"would give the copy the original's cache and run its cached methods on the original. Define "
|
|
82
|
+
"__copy__, or use on_copy='empty' or 'copy'."
|
|
83
|
+
)
|
|
84
|
+
elif theirs and explicit:
|
|
85
|
+
raise TypeError(
|
|
86
|
+
f"{where}: on_copy={on_copy!r} can't take effect: {owner.__qualname__} defines its own "
|
|
87
|
+
f"{' and '.join(theirs)}. Use on_copy='manual' and handle the cache there."
|
|
88
|
+
)
|
|
89
|
+
elif theirs:
|
|
90
|
+
_warn_once(owner, theirs)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _install_copy_methods(owner: type, theirs: list[str]) -> None:
|
|
94
|
+
"""Give ``owner`` our ``__copy__`` and ``__deepcopy__``, except where it has its own."""
|
|
95
|
+
for dunder, function in (("__copy__", _copy), ("__deepcopy__", _deepcopy)):
|
|
96
|
+
if dunder not in theirs and getattr(owner, dunder, None) is not function:
|
|
97
|
+
setattr(owner, dunder, function)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def user_copy_methods(cls: type) -> list[str]:
|
|
101
|
+
"""Which of ``__copy__``/``__deepcopy__`` ``cls`` gets from its own code (itself or a base), not from us."""
|
|
102
|
+
return [
|
|
103
|
+
dunder
|
|
104
|
+
for dunder in ("__copy__", "__deepcopy__")
|
|
105
|
+
if (method := getattr(cls, dunder, None)) is not None and not getattr(method, _OURS, False)
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_checked: "weakref.WeakSet[type]" = weakref.WeakSet()
|
|
110
|
+
_warned: "weakref.WeakSet[type]" = weakref.WeakSet()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_copy_handling(cls: type) -> None:
|
|
114
|
+
"""At an instance's first cache: warn (once per class) if ``cls`` replaced our copy methods.
|
|
115
|
+
|
|
116
|
+
Catches subclasses that define ``__copy__``/``__deepcopy__``, which class creation can't see.
|
|
117
|
+
"""
|
|
118
|
+
if cls in _checked:
|
|
119
|
+
return
|
|
120
|
+
_checked.add(cls)
|
|
121
|
+
theirs = user_copy_methods(cls)
|
|
122
|
+
if theirs and any(info.on_copy != "manual" for _, info in cached_methods(cls)):
|
|
123
|
+
_warn_once(cls, theirs)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _warn_once(cls: type, theirs: list[str]) -> None:
|
|
127
|
+
"""Give a :class:`CacheCopyWarning` for ``cls``, which defines its own ``theirs`` methods, once per class."""
|
|
128
|
+
if cls in _warned:
|
|
129
|
+
return
|
|
130
|
+
_warned.add(cls)
|
|
131
|
+
warnings.warn(
|
|
132
|
+
f"{cls.__qualname__} defines its own {' and '.join(theirs)}, so copies of its instances don't get "
|
|
133
|
+
"mcrotk_cache_utils' handling of its cached methods' caches. Make sure "
|
|
134
|
+
f"{'it doesn' if len(theirs) == 1 else 'they don'}'t give the copy the original's caches or the "
|
|
135
|
+
"cachetools wrappers in the instance __dict__ (bound to the original): copy_instance(self) makes "
|
|
136
|
+
"such a copy correctly. Then set on_copy='manual' on the cached methods to silence this warning.",
|
|
137
|
+
CacheCopyWarning,
|
|
138
|
+
stacklevel=_caller_outside_libraries(),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_PACKAGE_DIR = os.path.dirname(os.path.dirname(__file__)) # mcrotk_cache_utils/, above copying/
|
|
143
|
+
_SKIP_DIRS = (_PACKAGE_DIR + os.sep, os.path.dirname(cachetools.__file__) + os.sep)
|
|
144
|
+
_SKIP_FILES = (getattr(sys.modules.get("abc"), "__file__", None) or "",) # ABCMeta.__new__ runs __set_name__
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _caller_outside_libraries() -> int:
|
|
148
|
+
"""The stacklevel of the nearest frame outside this package (any of its folders), cachetools and abc."""
|
|
149
|
+
level, frame = 2, sys._getframe(2) # 1: _warn_once; 2: its caller
|
|
150
|
+
while frame is not None and (
|
|
151
|
+
frame.f_code.co_filename.startswith(_SKIP_DIRS) or frame.f_code.co_filename in _SKIP_FILES
|
|
152
|
+
):
|
|
153
|
+
level, frame = level + 1, frame.f_back
|
|
154
|
+
return level
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- The installed methods ---
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _copy(self: Any) -> Any:
|
|
161
|
+
return copy_with_caches(self, None)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _deepcopy(self: Any, memo: dict[int, Any]) -> Any:
|
|
165
|
+
return copy_with_caches(self, memo)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_copy.__name__ = _copy.__qualname__ = "__copy__"
|
|
169
|
+
_deepcopy.__name__ = _deepcopy.__qualname__ = "__deepcopy__"
|
|
170
|
+
_copy.__doc__ = "Shallow copy; cached methods' caches are emptied or copied according to their on_copy."
|
|
171
|
+
_deepcopy.__doc__ = "Deep copy; cached methods' caches are emptied or deep-copied according to their on_copy."
|
|
172
|
+
setattr(_copy, _OURS, True)
|
|
173
|
+
setattr(_deepcopy, _OURS, True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def copy_with_caches(obj: Any, memo: dict[int, Any] | None, methods: list[CachedMethodInfo] | None = None) -> Any:
|
|
177
|
+
"""Python's default copy (deep if ``memo`` is given), with the cache handling applied first."""
|
|
178
|
+
deep = memo is not None
|
|
179
|
+
if methods is None:
|
|
180
|
+
methods = [info for _, info in cached_methods(type(obj))]
|
|
181
|
+
rv = obj.__reduce_ex__(4)
|
|
182
|
+
if isinstance(rv, str): # a global (copied by reference), as in copy.copy
|
|
183
|
+
return obj
|
|
184
|
+
func, args, state, *rest = rv
|
|
185
|
+
if isinstance(state, dict):
|
|
186
|
+
state, prepared = _prepare(state, methods, deep), True
|
|
187
|
+
elif isinstance(state, tuple) and len(state) == 2 and isinstance(state[0], dict): # (__dict__, __slots__ values)
|
|
188
|
+
state, prepared = (_prepare(state[0], methods, deep), state[1]), True
|
|
189
|
+
else:
|
|
190
|
+
prepared = False # a custom __getstate__/__reduce__ state: fix the copy's __dict__ afterwards
|
|
191
|
+
new = copy._reconstruct(obj, memo, func, args, state, *rest) # pyright: ignore[reportAttributeAccessIssue]
|
|
192
|
+
if not prepared and isinstance(getattr(new, "__dict__", None), dict):
|
|
193
|
+
fixed = _prepare(new.__dict__, methods, deep)
|
|
194
|
+
new.__dict__.clear()
|
|
195
|
+
new.__dict__.update(fixed)
|
|
196
|
+
return new
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _prepare(instance_dict: dict[str, Any], methods: list[CachedMethodInfo], deep: bool) -> dict[str, Any]:
|
|
200
|
+
"""A copy of the instance's ``__dict__`` for the new instance: no wrappers; caches emptied or copied."""
|
|
201
|
+
result = dict(instance_dict)
|
|
202
|
+
result.pop(_WRAPPERS, None)
|
|
203
|
+
for info in methods:
|
|
204
|
+
if type(result.get(info.name)).__module__.startswith("cachetools"):
|
|
205
|
+
del result[info.name] # cachetools' wrapper, bound to the original
|
|
206
|
+
if info.cache_attr not in result or info.on_copy == "manual":
|
|
207
|
+
continue
|
|
208
|
+
if info.on_copy == "empty":
|
|
209
|
+
del result[info.cache_attr] # created again on first use
|
|
210
|
+
elif not deep: # "copy", shallow (a deep copy copies the cache with the rest of the state)
|
|
211
|
+
result[info.cache_attr] = copy_cache(result[info.cache_attr])
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def copy_cache(cache: MutableMapping) -> MutableMapping:
|
|
216
|
+
"""A new cache with the same entries: the cached keys and results are shared, not copied.
|
|
217
|
+
|
|
218
|
+
cachetools' caches don't support ``copy.copy`` (the copy would share their internal
|
|
219
|
+
structures), so the cache is deep-copied with its keys and results kept as they are. This
|
|
220
|
+
keeps e.g. an LRUCache's order and a TTLCache's expiry times.
|
|
221
|
+
"""
|
|
222
|
+
if type(cache) is dict:
|
|
223
|
+
return cache.copy()
|
|
224
|
+
keys = list(cache)
|
|
225
|
+
if isinstance(cache, cachetools.Cache):
|
|
226
|
+
# Read the values without "using" them: LRUCache.__getitem__ would change the original's order.
|
|
227
|
+
values = [cachetools.Cache.__getitem__(cache, key) for key in keys]
|
|
228
|
+
else:
|
|
229
|
+
values = [cache[key] for key in keys]
|
|
230
|
+
memo: dict[int, Any] = {id(obj): obj for obj in (*keys, *values)}
|
|
231
|
+
return copy.deepcopy(cache, memo)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Copying an instance with cached methods, choosing what the copy gets in their caches.
|
|
2
|
+
|
|
3
|
+
The machinery (the ``__copy__``/``__deepcopy__`` that ``cached_method`` installs, and their checks)
|
|
4
|
+
is in ``copy_handling``; ``CacheCopyWarning`` is defined there, where it's given.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Literal, TypeVar
|
|
8
|
+
|
|
9
|
+
from .copy_handling import CacheCopyWarning, cached_methods, copy_with_caches
|
|
10
|
+
|
|
11
|
+
__all__ = ["CacheCopyWarning", "copy_instance"]
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def copy_instance(
|
|
17
|
+
obj: T,
|
|
18
|
+
caches: Literal["empty", "copy"] | None = None,
|
|
19
|
+
*,
|
|
20
|
+
deep: bool = False,
|
|
21
|
+
memo: dict[int, Any] | None = None,
|
|
22
|
+
) -> T:
|
|
23
|
+
"""Copy an instance with cached methods, choosing what the copy gets in their caches.
|
|
24
|
+
|
|
25
|
+
Makes the same copy as ``copy.copy(obj)`` (or ``copy.deepcopy(obj)`` with ``deep=True``)
|
|
26
|
+
does with the copy methods ``cached_method`` installs, but never calls the class's own
|
|
27
|
+
``__copy__``/``__deepcopy__``, so it can be used inside them::
|
|
28
|
+
|
|
29
|
+
@cached_method(on_copy="manual")
|
|
30
|
+
def simulate(self, n): ...
|
|
31
|
+
|
|
32
|
+
def __copy__(self):
|
|
33
|
+
new = copy_instance(self)
|
|
34
|
+
... # anything else your copy needs
|
|
35
|
+
return new
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
obj: The instance to copy.
|
|
39
|
+
caches: ``'empty'`` or ``'copy'`` for every cached method's cache in the copy (see
|
|
40
|
+
``on_copy`` in :func:`cached_method`), or ``None`` (default) to follow each method's
|
|
41
|
+
``on_copy``, with ``'manual'`` methods' caches emptied.
|
|
42
|
+
deep: Make a deep copy.
|
|
43
|
+
memo: Inside your own ``__deepcopy__(self, memo)``, pass its ``memo`` (implies ``deep``),
|
|
44
|
+
so references shared with other parts of the object being copied stay shared.
|
|
45
|
+
"""
|
|
46
|
+
if caches not in (None, "empty", "copy"):
|
|
47
|
+
raise ValueError(f"copy_instance: caches must be 'empty', 'copy' or None, got {caches!r}")
|
|
48
|
+
if memo is None and deep:
|
|
49
|
+
memo = {}
|
|
50
|
+
methods = [
|
|
51
|
+
info._replace(on_copy=caches or ("empty" if info.on_copy == "manual" else info.on_copy))
|
|
52
|
+
for _, info in cached_methods(type(obj))
|
|
53
|
+
]
|
|
54
|
+
return copy_with_caches(obj, memo, methods)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The decorators, and ``cache_controls``/``clear_caches`` for what they return."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Working with the caches of cached functions and methods: ``cache_controls`` and ``clear_caches``.
|
|
2
|
+
|
|
3
|
+
``cache_controls`` gives typed access to a cached function's or method's cache attributes
|
|
4
|
+
(``cache_clear``, ``cache_info``, ...).
|
|
5
|
+
|
|
6
|
+
The decorators keep the decorated function's own type, so type checkers see a plain function and
|
|
7
|
+
don't know about the attributes cachetools adds. ``cache_controls`` gives them a type.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
from cachetools import CacheInfo
|
|
14
|
+
|
|
15
|
+
from ..copying.copy_handling import CACHE_ATTR
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CacheControls(Protocol):
|
|
19
|
+
"""The cache attributes of a cached function, or of a cached method on an instance."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def cache(self) -> Any:
|
|
23
|
+
"""The cache itself (what ``cache_factory`` made)."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def cache_key(self) -> Callable[..., Any]: ...
|
|
28
|
+
@property
|
|
29
|
+
def cache_lock(self) -> Any: ...
|
|
30
|
+
@property
|
|
31
|
+
def cache_condition(self) -> Any: ...
|
|
32
|
+
def cache_clear(self) -> None:
|
|
33
|
+
"""Empty the cache (and reset the ``info`` counters)."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def cache_info(self) -> CacheInfo:
|
|
37
|
+
"""Hit and miss counts. Only with ``info=True``: otherwise an ``AttributeError``."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cache_controls(cached: Callable[..., Any]) -> CacheControls:
|
|
42
|
+
"""The cache attributes of ``cached``, typed for type checkers.
|
|
43
|
+
|
|
44
|
+
``cached`` is a function decorated with ``cached_function`` (including a static or class
|
|
45
|
+
method, accessed as ``Class.method``), or a method decorated with ``cached_method`` accessed
|
|
46
|
+
on an instance (``obj.method``)::
|
|
47
|
+
|
|
48
|
+
cache_controls(fit).cache_clear()
|
|
49
|
+
cache_controls(model.fit).cache_info()
|
|
50
|
+
|
|
51
|
+
The attributes are the same as ``fit.cache_clear()`` etc., which also work at runtime but
|
|
52
|
+
which type checkers report as unknown. To empty an instance's caches, :func:`clear_caches`
|
|
53
|
+
also works.
|
|
54
|
+
"""
|
|
55
|
+
if not callable(getattr(cached, "cache_clear", None)):
|
|
56
|
+
hint = (
|
|
57
|
+
" (pass a cached method accessed on an instance, obj.method, not on the class)"
|
|
58
|
+
if type(cached).__module__.startswith("cachetools") # cachedmethod's class-level object
|
|
59
|
+
else ""
|
|
60
|
+
)
|
|
61
|
+
raise TypeError(f"cache_controls: {cached!r} is not a cached function or method{hint}")
|
|
62
|
+
return cached # type: ignore[return-value] # cachetools' wrapper (or a method bound to one) has these attributes
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def clear_caches(obj: Any, *methods: str) -> None:
|
|
66
|
+
"""Empty ``obj``'s caches for the named cached methods, or for all of them if none are named.
|
|
67
|
+
|
|
68
|
+
Only caches made by this library's decorators are touched. Use after changing an
|
|
69
|
+
attribute that affects cached results, e.g. in a copy made with ``on_copy='copy'``.
|
|
70
|
+
"""
|
|
71
|
+
found = set()
|
|
72
|
+
for cls in type(obj).__mro__:
|
|
73
|
+
for name, value in vars(cls).items():
|
|
74
|
+
attr = getattr(value, CACHE_ATTR, None)
|
|
75
|
+
if not isinstance(attr, str) or (methods and name not in methods):
|
|
76
|
+
continue
|
|
77
|
+
found.add(name)
|
|
78
|
+
if attr in getattr(obj, "__dict__", {}):
|
|
79
|
+
value.__get__(obj, cls).cache_clear() # through cachetools, so locks and info counters are handled
|
|
80
|
+
unknown = set(methods) - found
|
|
81
|
+
if unknown:
|
|
82
|
+
raise ValueError(f"{type(obj).__name__} has no cached method(s) named {sorted(unknown)}")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Caching decorators for functions, including static and class methods, with one cache per function."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable, Mapping, MutableMapping
|
|
6
|
+
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
|
7
|
+
|
|
8
|
+
import cachetools
|
|
9
|
+
|
|
10
|
+
from ..keys.cache_keys import ArgRef, ArgSpec, Passthrough
|
|
11
|
+
from ..keys.passthrough import make_view
|
|
12
|
+
from .support.decorator_options import FunctionOptions
|
|
13
|
+
from .support.placement_checks import FunctionNotInClassBody, add_check, reject_static_or_class_method
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from typing_extensions import Unpack
|
|
17
|
+
|
|
18
|
+
F = TypeVar("F", bound=Callable[..., Any]) # the decorated function: its type is kept as it is
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@overload
|
|
22
|
+
def cached_function(func: F, /, **options: Unpack[FunctionOptions]) -> F: ...
|
|
23
|
+
@overload
|
|
24
|
+
def cached_function(func: None = None, /, **options: Unpack[FunctionOptions]) -> Callable[[F], F]: ...
|
|
25
|
+
def cached_function(
|
|
26
|
+
func: Callable[..., Any] | None = None,
|
|
27
|
+
/,
|
|
28
|
+
*,
|
|
29
|
+
cache_factory: Callable[[], MutableMapping] = dict,
|
|
30
|
+
omit: ArgRef | Iterable[ArgRef] = (),
|
|
31
|
+
passthrough: Mapping[ArgRef, int | Passthrough] | None = None,
|
|
32
|
+
key_converters: Mapping[ArgRef, Callable[[Any], Any]] | None = None,
|
|
33
|
+
lock: Any = None,
|
|
34
|
+
condition: Any = None,
|
|
35
|
+
info: bool = False,
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Cache a function's results.
|
|
38
|
+
|
|
39
|
+
Use as ``@cached_function`` or ``@cached_function(...)``. Built on
|
|
40
|
+
:func:`cachetools.cached`, which does the calling, locking and statistics. The options are
|
|
41
|
+
the same as for :func:`cached_method`, except that ``lock`` and ``condition`` are objects
|
|
42
|
+
rather than functions of ``self``.
|
|
43
|
+
|
|
44
|
+
**The cache.** One per function, made by ``cache_factory`` when the decorator is applied,
|
|
45
|
+
and available as ``cache_controls(f).cache``. It lives as long as the function does,
|
|
46
|
+
usually the whole program, so with the default (an unbounded ``dict``) it keeps every
|
|
47
|
+
result until ``cache_controls(f).cache_clear()`` is called. Pass e.g.
|
|
48
|
+
``cache_factory=lambda: cachetools.LRUCache(maxsize=128)`` to bound it.
|
|
49
|
+
|
|
50
|
+
**The key.** Built from the call's arguments bound to the signature with defaults filled
|
|
51
|
+
in, so ``f(1)``, ``f(x=1)`` and ``f()`` with default ``x=1`` are the same call. Unhashable
|
|
52
|
+
arrays and containers are converted as for :func:`cached_method`. Positions in the options
|
|
53
|
+
count the parameters as in the ``def`` line, from 0.
|
|
54
|
+
|
|
55
|
+
**Types.** Type checkers see the decorated function's own type (its signature and return
|
|
56
|
+
type). The cache attributes cachetools adds (``f.cache_clear()``, ...) work, but aren't part
|
|
57
|
+
of that type: :func:`cache_controls` gives typed access to them.
|
|
58
|
+
|
|
59
|
+
**Static and class methods.** Put ``@staticmethod`` or ``@classmethod`` above this decorator.
|
|
60
|
+
For a class method, ``cls`` is part of the key, so subclasses get their own entries (in one
|
|
61
|
+
shared cache); ``omit=['cls']`` makes them share results. For ordinary methods use
|
|
62
|
+
:func:`cached_method`: using this decorator directly in a class body is an error.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
cache_factory: Makes the cache. Default ``dict`` (unbounded).
|
|
66
|
+
omit, passthrough, key_converters: As for :func:`cached_method`.
|
|
67
|
+
lock, condition, info: Passed to :func:`cachetools.cached`. A ``dict`` cache is safe
|
|
68
|
+
to use from several threads without a lock (at worst two threads compute the same
|
|
69
|
+
result); cachetools' caches (``LRUCache`` etc.) are not, so pass e.g.
|
|
70
|
+
``lock=threading.Lock()`` with them. ``condition`` also stops two threads from
|
|
71
|
+
computing the same result. With ``info=True``, ``cache_controls(f).cache_info()``
|
|
72
|
+
gives hit and miss counts.
|
|
73
|
+
|
|
74
|
+
Caveats:
|
|
75
|
+
- Results are returned as stored, not copied: changing a returned object in place
|
|
76
|
+
changes the cached result.
|
|
77
|
+
- Each process has its own cache: worker processes (e.g. in a ``ProcessPoolExecutor``)
|
|
78
|
+
start with empty caches, and their results don't reach the parent's cache.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def decorate(func: Callable[..., Any]) -> Any:
|
|
82
|
+
"""Wrap ``func`` with cachetools, with its key spec and its one cache."""
|
|
83
|
+
reject_static_or_class_method(
|
|
84
|
+
func, "cached_function", "Put @staticmethod/@classmethod above @cached_function instead."
|
|
85
|
+
)
|
|
86
|
+
spec = ArgSpec(func, has_self=False, omit=omit, passthrough=passthrough, key_converters=key_converters)
|
|
87
|
+
cache = cache_factory()
|
|
88
|
+
if spec.passthrough:
|
|
89
|
+
cache = make_view(cache, spec.qualname)
|
|
90
|
+
wrapper = cachetools.cached(cache, key=spec.make_key, lock=lock, condition=condition, info=info)(func)
|
|
91
|
+
add_check(wrapper, FunctionNotInClassBody)
|
|
92
|
+
return wrapper
|
|
93
|
+
|
|
94
|
+
return decorate if func is None else decorate(func)
|