acachetools 0.1.0__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.
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: acachetools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async cachetools decorators with stampede protection.
|
|
5
|
+
Author: RektPunk
|
|
6
|
+
Author-email: RektPunk <rektpunk@gmail.com>
|
|
7
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
8
|
+
Requires-Dist: cachetools>=7.1.4
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Project-URL: repository, https://github.com/RektPunk/acachetools
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
<div style="text-align: center;">
|
|
14
|
+
<img src="https://capsule-render.vercel.app/api?type=transparent&fontColor=0047AB&text=acachetools&height=120&fontSize=90">
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
**acachetools** provides asyncio-compatible versions of `cachetools.cached()` and `cachetools.cachedmethod()`. Concurrent calls for the same cache key share a single in-flight computation, preventing cache stampedes.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
```bash
|
|
21
|
+
pip install acachetools
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
```python
|
|
26
|
+
from cachetools import TTLCache
|
|
27
|
+
from acachetools import cached
|
|
28
|
+
|
|
29
|
+
# Compatible with TTLCache, LRUCache, LFUCache, RRCache, etc.
|
|
30
|
+
@cached(cache=TTLCache(maxsize=1024, ttl=600))
|
|
31
|
+
async def foo(bar: int):
|
|
32
|
+
...
|
|
33
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<div style="text-align: center;">
|
|
2
|
+
<img src="https://capsule-render.vercel.app/api?type=transparent&fontColor=0047AB&text=acachetools&height=120&fontSize=90">
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
**acachetools** provides asyncio-compatible versions of `cachetools.cached()` and `cachetools.cachedmethod()`. Concurrent calls for the same cache key share a single in-flight computation, preventing cache stampedes.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
```bash
|
|
9
|
+
pip install acachetools
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
```python
|
|
14
|
+
from cachetools import TTLCache
|
|
15
|
+
from acachetools import cached
|
|
16
|
+
|
|
17
|
+
# Compatible with TTLCache, LRUCache, LFUCache, RRCache, etc.
|
|
18
|
+
@cached(cache=TTLCache(maxsize=1024, ttl=600))
|
|
19
|
+
async def foo(bar: int):
|
|
20
|
+
...
|
|
21
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "acachetools"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Async cachetools decorators with stampede protection."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{name = "RektPunk", email = "rektpunk@gmail.com"},
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Topic :: Software Development :: Libraries :: Python Modules"
|
|
12
|
+
]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"cachetools>=7.1.4",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
repository = "https://github.com/RektPunk/acachetools"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.11.23,<0.12.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=9.1.1",
|
|
27
|
+
"pytest-asyncio>=1.4.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
3
|
+
from functools import update_wrapper
|
|
4
|
+
from inspect import iscoroutinefunction
|
|
5
|
+
from typing import Any, ParamSpec, Protocol, TypeVar, cast, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from cachetools.keys import hashkey, methodkey
|
|
8
|
+
|
|
9
|
+
P = ParamSpec("P")
|
|
10
|
+
R = TypeVar("R", covariant=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class CachedAsyncFunction(Protocol[P, R]):
|
|
15
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Awaitable[R]: ...
|
|
16
|
+
|
|
17
|
+
cache: MutableMapping[Any, Any]
|
|
18
|
+
cache_clear: Callable[[], None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class CachedAsyncMethod(Protocol[P, R]):
|
|
23
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Awaitable[R]: ...
|
|
24
|
+
|
|
25
|
+
cache: Callable[[Any], MutableMapping[Any, Any]]
|
|
26
|
+
cache_clear: Callable[[Any], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _run_cached(
|
|
30
|
+
cache_store: MutableMapping[Any, asyncio.Future[R]],
|
|
31
|
+
cache_key: Any,
|
|
32
|
+
coro_factory: Callable[[], Awaitable[R]],
|
|
33
|
+
) -> R:
|
|
34
|
+
while True:
|
|
35
|
+
future = cache_store.get(cache_key)
|
|
36
|
+
|
|
37
|
+
# Cache hit
|
|
38
|
+
if future is not None:
|
|
39
|
+
if future.cancelled():
|
|
40
|
+
# Cached Future was cancelled
|
|
41
|
+
if cache_store.get(cache_key) is future:
|
|
42
|
+
cache_store.pop(cache_key, None)
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
if not future.done():
|
|
46
|
+
# Another task is still computing this key
|
|
47
|
+
# Wait for the shared result instead of recomputing
|
|
48
|
+
try:
|
|
49
|
+
return await asyncio.shield(future)
|
|
50
|
+
except asyncio.CancelledError:
|
|
51
|
+
# The caller was cancelled while waiting
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
# Cached computation completed
|
|
55
|
+
try:
|
|
56
|
+
return future.result()
|
|
57
|
+
except Exception:
|
|
58
|
+
# Failed results are not cached
|
|
59
|
+
if cache_store.get(cache_key) is future:
|
|
60
|
+
cache_store.pop(cache_key, None)
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
# Cache miss
|
|
64
|
+
loop = asyncio.get_running_loop()
|
|
65
|
+
shared_future: asyncio.Future[R] = loop.create_future()
|
|
66
|
+
existing = cache_store.setdefault(
|
|
67
|
+
cache_key,
|
|
68
|
+
shared_future,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Another task already registered a Future for this cache key.
|
|
72
|
+
if existing is not shared_future:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
result = await coro_factory()
|
|
77
|
+
|
|
78
|
+
if not shared_future.done():
|
|
79
|
+
shared_future.set_result(result)
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
except asyncio.CancelledError:
|
|
84
|
+
# The owner task was cancelled
|
|
85
|
+
if cache_store.get(cache_key) is shared_future:
|
|
86
|
+
cache_store.pop(cache_key, None)
|
|
87
|
+
|
|
88
|
+
if not shared_future.done():
|
|
89
|
+
shared_future.cancel()
|
|
90
|
+
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
# Exceptions are not cached
|
|
95
|
+
if cache_store.get(cache_key) is shared_future:
|
|
96
|
+
cache_store.pop(cache_key, None)
|
|
97
|
+
|
|
98
|
+
if not shared_future.done():
|
|
99
|
+
shared_future.set_exception(exc)
|
|
100
|
+
# Future exception was never retrieved
|
|
101
|
+
shared_future.exception()
|
|
102
|
+
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _clear_cache(
|
|
107
|
+
cache_store: MutableMapping[Any, asyncio.Future[Any]],
|
|
108
|
+
) -> None:
|
|
109
|
+
for future in list(cache_store.values()):
|
|
110
|
+
if not future.done():
|
|
111
|
+
future.cancel()
|
|
112
|
+
|
|
113
|
+
cache_store.clear()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cached(
|
|
117
|
+
cache: MutableMapping[Any, Any] | None = None,
|
|
118
|
+
*,
|
|
119
|
+
key: Callable[..., Any] = hashkey,
|
|
120
|
+
info: bool = False,
|
|
121
|
+
lock: Any | None = None,
|
|
122
|
+
) -> Callable[[Callable[P, Awaitable[R]]], CachedAsyncFunction[P, R]]:
|
|
123
|
+
if info:
|
|
124
|
+
raise NotImplementedError("acachetools does not support `info`.")
|
|
125
|
+
if lock is not None:
|
|
126
|
+
raise NotImplementedError("acachetools does not support `lock`.")
|
|
127
|
+
|
|
128
|
+
cache_store = cast(
|
|
129
|
+
MutableMapping[Any, asyncio.Future[R]],
|
|
130
|
+
{} if cache is None else cache,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def decorator(
|
|
134
|
+
fn: Callable[P, Awaitable[R]],
|
|
135
|
+
) -> CachedAsyncFunction[P, R]:
|
|
136
|
+
if not iscoroutinefunction(fn):
|
|
137
|
+
raise TypeError(f"Expected Coroutine function, got {fn}")
|
|
138
|
+
|
|
139
|
+
async def wrapper(
|
|
140
|
+
*args: P.args,
|
|
141
|
+
**kwargs: P.kwargs,
|
|
142
|
+
) -> R:
|
|
143
|
+
return await _run_cached(
|
|
144
|
+
cache_store=cache_store,
|
|
145
|
+
cache_key=key(*args, **kwargs),
|
|
146
|
+
coro_factory=lambda: fn(*args, **kwargs),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def cache_clear() -> None:
|
|
150
|
+
_clear_cache(cache_store)
|
|
151
|
+
|
|
152
|
+
wrapped = update_wrapper(wrapper, fn)
|
|
153
|
+
wrapped.cache = cache_store # type: ignore[attr-defined]
|
|
154
|
+
wrapped.cache_clear = cache_clear # type: ignore[attr-defined]
|
|
155
|
+
return wrapped # type: ignore[return-value]
|
|
156
|
+
|
|
157
|
+
return decorator
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cachedmethod(
|
|
161
|
+
cache: Callable[[Any], MutableMapping[Any, Any]],
|
|
162
|
+
*,
|
|
163
|
+
key: Callable[..., Any] = methodkey,
|
|
164
|
+
lock: Callable[[Any], Any] | None = None,
|
|
165
|
+
) -> Callable[[Callable[..., Awaitable[R]]], CachedAsyncMethod[P, R]]:
|
|
166
|
+
if lock is not None:
|
|
167
|
+
raise NotImplementedError("acachetools does not support `lock`.")
|
|
168
|
+
|
|
169
|
+
def decorator(method: Callable[..., Awaitable[R]]) -> CachedAsyncMethod[P, R]:
|
|
170
|
+
if not iscoroutinefunction(method):
|
|
171
|
+
raise TypeError(f"Expected coroutine function, got {method!r}")
|
|
172
|
+
|
|
173
|
+
async def wrapper(
|
|
174
|
+
self: Any,
|
|
175
|
+
*args: P.args,
|
|
176
|
+
**kwargs: P.kwargs,
|
|
177
|
+
) -> R:
|
|
178
|
+
cache_store = cast(
|
|
179
|
+
MutableMapping[Any, asyncio.Future[R]],
|
|
180
|
+
cache(self),
|
|
181
|
+
)
|
|
182
|
+
return await _run_cached(
|
|
183
|
+
cache_store=cache_store,
|
|
184
|
+
cache_key=key(self, *args, **kwargs),
|
|
185
|
+
coro_factory=lambda: method(
|
|
186
|
+
self,
|
|
187
|
+
*args,
|
|
188
|
+
**kwargs,
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def cache_clear(self: Any) -> None:
|
|
193
|
+
cache_store = cast(
|
|
194
|
+
MutableMapping[Any, asyncio.Future[Any]],
|
|
195
|
+
cache(self),
|
|
196
|
+
)
|
|
197
|
+
_clear_cache(cache_store)
|
|
198
|
+
|
|
199
|
+
wrapped = update_wrapper(wrapper, method)
|
|
200
|
+
wrapped.cache = cache # type: ignore[attr-defined]
|
|
201
|
+
wrapped.cache_clear = cache_clear # type: ignore[attr-defined]
|
|
202
|
+
|
|
203
|
+
return wrapped # type: ignore[return-value]
|
|
204
|
+
|
|
205
|
+
return decorator
|
|
File without changes
|