cachu 0.2.3__py3-none-any.whl → 0.2.5__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.
- cachu/__init__.py +8 -12
- cachu/backends/__init__.py +65 -5
- cachu/backends/memory.py +159 -57
- cachu/backends/redis.py +160 -28
- cachu/backends/sqlite.py +326 -41
- cachu/config.py +6 -6
- cachu/decorator.py +354 -87
- cachu/keys.py +8 -0
- cachu/mutex.py +247 -0
- cachu/operations.py +171 -23
- {cachu-0.2.3.dist-info → cachu-0.2.5.dist-info}/METADATA +7 -10
- cachu-0.2.5.dist-info/RECORD +15 -0
- cachu/async_decorator.py +0 -262
- cachu/async_operations.py +0 -178
- cachu/backends/async_base.py +0 -50
- cachu/backends/async_memory.py +0 -111
- cachu/backends/async_redis.py +0 -141
- cachu/backends/async_sqlite.py +0 -256
- cachu/backends/file.py +0 -10
- cachu-0.2.3.dist-info/RECORD +0 -21
- {cachu-0.2.3.dist-info → cachu-0.2.5.dist-info}/WHEEL +0 -0
- {cachu-0.2.3.dist-info → cachu-0.2.5.dist-info}/top_level.txt +0 -0
cachu/async_decorator.py
DELETED
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
"""Async cache decorator implementation.
|
|
2
|
-
"""
|
|
3
|
-
import asyncio
|
|
4
|
-
import logging
|
|
5
|
-
import os
|
|
6
|
-
import time
|
|
7
|
-
from collections.abc import Awaitable, Callable
|
|
8
|
-
from functools import wraps
|
|
9
|
-
from typing import Any
|
|
10
|
-
|
|
11
|
-
from .backends import NO_VALUE
|
|
12
|
-
from .backends.async_base import AsyncBackend
|
|
13
|
-
from .backends.async_memory import AsyncMemoryBackend
|
|
14
|
-
from .config import _get_caller_package, get_config, is_disabled
|
|
15
|
-
from .keys import make_key_generator, mangle_key
|
|
16
|
-
from .types import CacheEntry, CacheInfo, CacheMeta
|
|
17
|
-
|
|
18
|
-
logger = logging.getLogger(__name__)
|
|
19
|
-
|
|
20
|
-
_async_backends: dict[tuple[str | None, str, int], AsyncBackend] = {}
|
|
21
|
-
_async_backends_lock = asyncio.Lock()
|
|
22
|
-
|
|
23
|
-
_async_stats: dict[int, tuple[int, int]] = {}
|
|
24
|
-
_async_stats_lock = asyncio.Lock()
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
async def _get_async_backend(package: str | None, backend_type: str, ttl: int) -> AsyncBackend:
|
|
28
|
-
"""Get or create an async backend instance.
|
|
29
|
-
"""
|
|
30
|
-
key = (package, backend_type, ttl)
|
|
31
|
-
|
|
32
|
-
async with _async_backends_lock:
|
|
33
|
-
if key in _async_backends:
|
|
34
|
-
return _async_backends[key]
|
|
35
|
-
|
|
36
|
-
cfg = get_config(package)
|
|
37
|
-
|
|
38
|
-
if backend_type == 'memory':
|
|
39
|
-
backend: AsyncBackend = AsyncMemoryBackend()
|
|
40
|
-
elif backend_type == 'file':
|
|
41
|
-
from .backends.async_sqlite import AsyncSqliteBackend
|
|
42
|
-
|
|
43
|
-
if ttl < 60:
|
|
44
|
-
filename = f'cache{ttl}sec.db'
|
|
45
|
-
elif ttl < 3600:
|
|
46
|
-
filename = f'cache{ttl // 60}min.db'
|
|
47
|
-
else:
|
|
48
|
-
filename = f'cache{ttl // 3600}hour.db'
|
|
49
|
-
|
|
50
|
-
if package:
|
|
51
|
-
filename = f'{package}_{filename}'
|
|
52
|
-
|
|
53
|
-
filepath = os.path.join(cfg.file_dir, filename)
|
|
54
|
-
backend = AsyncSqliteBackend(filepath)
|
|
55
|
-
elif backend_type == 'redis':
|
|
56
|
-
from .backends.async_redis import AsyncRedisBackend
|
|
57
|
-
backend = AsyncRedisBackend(cfg.redis_url, cfg.redis_distributed)
|
|
58
|
-
else:
|
|
59
|
-
raise ValueError(f'Unknown backend type: {backend_type}')
|
|
60
|
-
|
|
61
|
-
_async_backends[key] = backend
|
|
62
|
-
logger.debug(f"Created async {backend_type} backend for package '{package}', {ttl}s TTL")
|
|
63
|
-
return backend
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
async def get_async_backend(
|
|
67
|
-
backend_type: str | None = None,
|
|
68
|
-
package: str | None = None,
|
|
69
|
-
*,
|
|
70
|
-
ttl: int,
|
|
71
|
-
) -> AsyncBackend:
|
|
72
|
-
"""Get an async backend instance.
|
|
73
|
-
|
|
74
|
-
Args:
|
|
75
|
-
backend_type: 'memory', 'file', or 'redis'. Uses config default if None.
|
|
76
|
-
package: Package name. Auto-detected if None.
|
|
77
|
-
ttl: TTL in seconds (used for backend separation).
|
|
78
|
-
"""
|
|
79
|
-
if package is None:
|
|
80
|
-
package = _get_caller_package()
|
|
81
|
-
|
|
82
|
-
if backend_type is None:
|
|
83
|
-
cfg = get_config(package)
|
|
84
|
-
backend_type = cfg.backend
|
|
85
|
-
|
|
86
|
-
return await _get_async_backend(package, backend_type, ttl)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def async_cache(
|
|
90
|
-
ttl: int = 300,
|
|
91
|
-
backend: str | None = None,
|
|
92
|
-
tag: str = '',
|
|
93
|
-
exclude: set[str] | None = None,
|
|
94
|
-
cache_if: Callable[[Any], bool] | None = None,
|
|
95
|
-
validate: Callable[[CacheEntry], bool] | None = None,
|
|
96
|
-
package: str | None = None,
|
|
97
|
-
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
|
|
98
|
-
"""Async cache decorator with configurable backend and behavior.
|
|
99
|
-
|
|
100
|
-
Args:
|
|
101
|
-
ttl: Time-to-live in seconds (default: 300)
|
|
102
|
-
backend: Backend type ('memory', 'file', 'redis'). Uses config default if None.
|
|
103
|
-
tag: Tag for grouping related cache entries
|
|
104
|
-
exclude: Parameter names to exclude from cache key
|
|
105
|
-
cache_if: Function to determine if result should be cached.
|
|
106
|
-
Called with result value, caches if returns True.
|
|
107
|
-
validate: Function to validate cached entries before returning.
|
|
108
|
-
Called with CacheEntry, returns False to recompute.
|
|
109
|
-
package: Package name for config isolation. Auto-detected if None.
|
|
110
|
-
|
|
111
|
-
Per-call control via reserved kwargs (not passed to function):
|
|
112
|
-
_skip_cache: If True, bypass cache completely for this call
|
|
113
|
-
_overwrite_cache: If True, execute function and overwrite cached value
|
|
114
|
-
|
|
115
|
-
Example:
|
|
116
|
-
@async_cache(ttl=300, tag='users')
|
|
117
|
-
async def get_user(user_id: int) -> dict:
|
|
118
|
-
return await fetch_user(user_id)
|
|
119
|
-
|
|
120
|
-
# Normal call
|
|
121
|
-
user = await get_user(123)
|
|
122
|
-
|
|
123
|
-
# Skip cache
|
|
124
|
-
user = await get_user(123, _skip_cache=True)
|
|
125
|
-
|
|
126
|
-
# Force refresh
|
|
127
|
-
user = await get_user(123, _overwrite_cache=True)
|
|
128
|
-
"""
|
|
129
|
-
resolved_package = package if package is not None else _get_caller_package()
|
|
130
|
-
|
|
131
|
-
if backend is None:
|
|
132
|
-
cfg = get_config(resolved_package)
|
|
133
|
-
resolved_backend = cfg.backend
|
|
134
|
-
else:
|
|
135
|
-
resolved_backend = backend
|
|
136
|
-
|
|
137
|
-
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
|
|
138
|
-
key_generator = make_key_generator(fn, tag, exclude)
|
|
139
|
-
|
|
140
|
-
meta = CacheMeta(
|
|
141
|
-
ttl=ttl,
|
|
142
|
-
backend=resolved_backend,
|
|
143
|
-
tag=tag,
|
|
144
|
-
exclude=exclude or set(),
|
|
145
|
-
cache_if=cache_if,
|
|
146
|
-
validate=validate,
|
|
147
|
-
package=resolved_package,
|
|
148
|
-
key_generator=key_generator,
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
@wraps(fn)
|
|
152
|
-
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
153
|
-
skip_cache = kwargs.pop('_skip_cache', False)
|
|
154
|
-
overwrite_cache = kwargs.pop('_overwrite_cache', False)
|
|
155
|
-
|
|
156
|
-
if is_disabled() or skip_cache:
|
|
157
|
-
return await fn(*args, **kwargs)
|
|
158
|
-
|
|
159
|
-
backend_instance = await _get_async_backend(resolved_package, resolved_backend, ttl)
|
|
160
|
-
cfg = get_config(resolved_package)
|
|
161
|
-
|
|
162
|
-
base_key = key_generator(*args, **kwargs)
|
|
163
|
-
cache_key = mangle_key(base_key, cfg.key_prefix, ttl)
|
|
164
|
-
|
|
165
|
-
if not overwrite_cache:
|
|
166
|
-
value, created_at = await backend_instance.get_with_metadata(cache_key)
|
|
167
|
-
|
|
168
|
-
if value is not NO_VALUE:
|
|
169
|
-
if validate is not None and created_at is not None:
|
|
170
|
-
entry = CacheEntry(
|
|
171
|
-
value=value,
|
|
172
|
-
created_at=created_at,
|
|
173
|
-
age=time.time() - created_at,
|
|
174
|
-
)
|
|
175
|
-
if not validate(entry):
|
|
176
|
-
logger.debug(f'Cache validation failed for {fn.__name__}')
|
|
177
|
-
else:
|
|
178
|
-
await _record_async_hit(wrapper)
|
|
179
|
-
return value
|
|
180
|
-
else:
|
|
181
|
-
await _record_async_hit(wrapper)
|
|
182
|
-
return value
|
|
183
|
-
|
|
184
|
-
await _record_async_miss(wrapper)
|
|
185
|
-
result = await fn(*args, **kwargs)
|
|
186
|
-
|
|
187
|
-
should_cache = cache_if is None or cache_if(result)
|
|
188
|
-
|
|
189
|
-
if should_cache:
|
|
190
|
-
await backend_instance.set(cache_key, result, ttl)
|
|
191
|
-
logger.debug(f'Cached {fn.__name__} with key {cache_key}')
|
|
192
|
-
|
|
193
|
-
return result
|
|
194
|
-
|
|
195
|
-
wrapper._cache_meta = meta # type: ignore
|
|
196
|
-
wrapper._cache_key_generator = key_generator # type: ignore
|
|
197
|
-
|
|
198
|
-
return wrapper
|
|
199
|
-
|
|
200
|
-
return decorator
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
async def _record_async_hit(fn: Callable[..., Any]) -> None:
|
|
204
|
-
"""Record a cache hit for the async function.
|
|
205
|
-
"""
|
|
206
|
-
fn_id = id(fn)
|
|
207
|
-
async with _async_stats_lock:
|
|
208
|
-
hits, misses = _async_stats.get(fn_id, (0, 0))
|
|
209
|
-
_async_stats[fn_id] = (hits + 1, misses)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
async def _record_async_miss(fn: Callable[..., Any]) -> None:
|
|
213
|
-
"""Record a cache miss for the async function.
|
|
214
|
-
"""
|
|
215
|
-
fn_id = id(fn)
|
|
216
|
-
async with _async_stats_lock:
|
|
217
|
-
hits, misses = _async_stats.get(fn_id, (0, 0))
|
|
218
|
-
_async_stats[fn_id] = (hits, misses + 1)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
async def get_async_cache_info(fn: Callable[..., Any]) -> CacheInfo:
|
|
222
|
-
"""Get cache statistics for an async decorated function.
|
|
223
|
-
|
|
224
|
-
Args:
|
|
225
|
-
fn: A function decorated with @async_cache
|
|
226
|
-
|
|
227
|
-
Returns
|
|
228
|
-
CacheInfo with hits, misses, and currsize
|
|
229
|
-
"""
|
|
230
|
-
fn_id = id(fn)
|
|
231
|
-
|
|
232
|
-
async with _async_stats_lock:
|
|
233
|
-
hits, misses = _async_stats.get(fn_id, (0, 0))
|
|
234
|
-
|
|
235
|
-
meta = getattr(fn, '_cache_meta', None)
|
|
236
|
-
if meta is None:
|
|
237
|
-
return CacheInfo(hits=hits, misses=misses, currsize=0)
|
|
238
|
-
|
|
239
|
-
backend_instance = await _get_async_backend(meta.package, meta.backend, meta.ttl)
|
|
240
|
-
cfg = get_config(meta.package)
|
|
241
|
-
|
|
242
|
-
fn_name = getattr(fn, '__wrapped__', fn).__name__
|
|
243
|
-
pattern = f'*:{cfg.key_prefix}{fn_name}|*'
|
|
244
|
-
|
|
245
|
-
currsize = await backend_instance.count(pattern)
|
|
246
|
-
|
|
247
|
-
return CacheInfo(hits=hits, misses=misses, currsize=currsize)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
async def clear_async_backends(package: str | None = None) -> None:
|
|
251
|
-
"""Clear all async backend instances for a package. Primarily for testing.
|
|
252
|
-
"""
|
|
253
|
-
async with _async_backends_lock:
|
|
254
|
-
if package is None:
|
|
255
|
-
for backend in _async_backends.values():
|
|
256
|
-
await backend.close()
|
|
257
|
-
_async_backends.clear()
|
|
258
|
-
else:
|
|
259
|
-
keys_to_delete = [k for k in _async_backends if k[0] == package]
|
|
260
|
-
for key in keys_to_delete:
|
|
261
|
-
await _async_backends[key].close()
|
|
262
|
-
del _async_backends[key]
|
cachu/async_operations.py
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
"""Async cache CRUD operations.
|
|
2
|
-
"""
|
|
3
|
-
import logging
|
|
4
|
-
from collections.abc import Callable
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from .backends import NO_VALUE
|
|
8
|
-
from .config import _get_caller_package, get_config
|
|
9
|
-
from .async_decorator import _get_async_backend, get_async_cache_info
|
|
10
|
-
from .async_decorator import _async_backends, _async_backends_lock
|
|
11
|
-
from .keys import mangle_key
|
|
12
|
-
from .types import CacheInfo, CacheMeta
|
|
13
|
-
|
|
14
|
-
logger = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
_MISSING = object()
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _get_meta(fn: Callable[..., Any]) -> CacheMeta:
|
|
20
|
-
"""Get CacheMeta from a decorated function.
|
|
21
|
-
"""
|
|
22
|
-
meta = getattr(fn, '_cache_meta', None)
|
|
23
|
-
if meta is None:
|
|
24
|
-
raise ValueError(f'{fn.__name__} is not decorated with @async_cache')
|
|
25
|
-
return meta
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
async def async_cache_get(fn: Callable[..., Any], default: Any = _MISSING, **kwargs: Any) -> Any:
|
|
29
|
-
"""Get a cached value without calling the async function.
|
|
30
|
-
|
|
31
|
-
Args:
|
|
32
|
-
fn: A function decorated with @async_cache
|
|
33
|
-
default: Value to return if not found (raises KeyError if not provided)
|
|
34
|
-
**kwargs: Function arguments to build the cache key
|
|
35
|
-
|
|
36
|
-
Returns
|
|
37
|
-
The cached value or default
|
|
38
|
-
|
|
39
|
-
Raises
|
|
40
|
-
KeyError: If not found and no default provided
|
|
41
|
-
ValueError: If function is not decorated with @async_cache
|
|
42
|
-
"""
|
|
43
|
-
meta = _get_meta(fn)
|
|
44
|
-
cfg = get_config(meta.package)
|
|
45
|
-
|
|
46
|
-
key_generator = fn._cache_key_generator
|
|
47
|
-
base_key = key_generator(**kwargs)
|
|
48
|
-
cache_key = mangle_key(base_key, cfg.key_prefix, meta.ttl)
|
|
49
|
-
|
|
50
|
-
backend = await _get_async_backend(meta.package, meta.backend, meta.ttl)
|
|
51
|
-
value = await backend.get(cache_key)
|
|
52
|
-
|
|
53
|
-
if value is NO_VALUE:
|
|
54
|
-
if default is _MISSING:
|
|
55
|
-
raise KeyError(f'No cached value for {fn.__name__} with {kwargs}')
|
|
56
|
-
return default
|
|
57
|
-
|
|
58
|
-
return value
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
async def async_cache_set(fn: Callable[..., Any], value: Any, **kwargs: Any) -> None:
|
|
62
|
-
"""Set a cached value directly without calling the async function.
|
|
63
|
-
|
|
64
|
-
Args:
|
|
65
|
-
fn: A function decorated with @async_cache
|
|
66
|
-
value: The value to cache
|
|
67
|
-
**kwargs: Function arguments to build the cache key
|
|
68
|
-
|
|
69
|
-
Raises
|
|
70
|
-
ValueError: If function is not decorated with @async_cache
|
|
71
|
-
"""
|
|
72
|
-
meta = _get_meta(fn)
|
|
73
|
-
cfg = get_config(meta.package)
|
|
74
|
-
|
|
75
|
-
key_generator = fn._cache_key_generator
|
|
76
|
-
base_key = key_generator(**kwargs)
|
|
77
|
-
cache_key = mangle_key(base_key, cfg.key_prefix, meta.ttl)
|
|
78
|
-
|
|
79
|
-
backend = await _get_async_backend(meta.package, meta.backend, meta.ttl)
|
|
80
|
-
await backend.set(cache_key, value, meta.ttl)
|
|
81
|
-
|
|
82
|
-
logger.debug(f'Set cache for {fn.__name__} with key {cache_key}')
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
async def async_cache_delete(fn: Callable[..., Any], **kwargs: Any) -> None:
|
|
86
|
-
"""Delete a specific cached entry.
|
|
87
|
-
|
|
88
|
-
Args:
|
|
89
|
-
fn: A function decorated with @async_cache
|
|
90
|
-
**kwargs: Function arguments to build the cache key
|
|
91
|
-
|
|
92
|
-
Raises
|
|
93
|
-
ValueError: If function is not decorated with @async_cache
|
|
94
|
-
"""
|
|
95
|
-
meta = _get_meta(fn)
|
|
96
|
-
cfg = get_config(meta.package)
|
|
97
|
-
|
|
98
|
-
key_generator = fn._cache_key_generator
|
|
99
|
-
base_key = key_generator(**kwargs)
|
|
100
|
-
cache_key = mangle_key(base_key, cfg.key_prefix, meta.ttl)
|
|
101
|
-
|
|
102
|
-
backend = await _get_async_backend(meta.package, meta.backend, meta.ttl)
|
|
103
|
-
await backend.delete(cache_key)
|
|
104
|
-
|
|
105
|
-
logger.debug(f'Deleted cache for {fn.__name__} with key {cache_key}')
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
async def async_cache_clear(
|
|
109
|
-
tag: str | None = None,
|
|
110
|
-
backend: str | None = None,
|
|
111
|
-
ttl: int | None = None,
|
|
112
|
-
package: str | None = None,
|
|
113
|
-
) -> int:
|
|
114
|
-
"""Clear async cache entries matching criteria.
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
tag: Clear only entries with this tag
|
|
118
|
-
backend: Backend type to clear ('memory', 'file', 'redis'). Clears all if None.
|
|
119
|
-
ttl: Specific TTL region to clear. Clears all TTLs if None.
|
|
120
|
-
package: Package to clear for. Auto-detected if None.
|
|
121
|
-
|
|
122
|
-
Returns
|
|
123
|
-
Number of entries cleared (may be approximate)
|
|
124
|
-
"""
|
|
125
|
-
if package is None:
|
|
126
|
-
package = _get_caller_package()
|
|
127
|
-
|
|
128
|
-
if backend is not None:
|
|
129
|
-
backends_to_clear = [backend]
|
|
130
|
-
else:
|
|
131
|
-
backends_to_clear = ['memory', 'file', 'redis']
|
|
132
|
-
|
|
133
|
-
if tag:
|
|
134
|
-
from .keys import _normalize_tag
|
|
135
|
-
pattern = f'*|{_normalize_tag(tag)}|*'
|
|
136
|
-
else:
|
|
137
|
-
pattern = None
|
|
138
|
-
|
|
139
|
-
total_cleared = 0
|
|
140
|
-
|
|
141
|
-
if backend is not None and ttl is not None:
|
|
142
|
-
backend_instance = await _get_async_backend(package, backend, ttl)
|
|
143
|
-
cleared = await backend_instance.clear(pattern)
|
|
144
|
-
if cleared > 0:
|
|
145
|
-
total_cleared += cleared
|
|
146
|
-
logger.debug(f'Cleared {cleared} entries from {backend} backend (ttl={ttl})')
|
|
147
|
-
else:
|
|
148
|
-
async with _async_backends_lock:
|
|
149
|
-
for (pkg, btype, bttl), backend_instance in list(_async_backends.items()):
|
|
150
|
-
if pkg != package:
|
|
151
|
-
continue
|
|
152
|
-
if btype not in backends_to_clear:
|
|
153
|
-
continue
|
|
154
|
-
if ttl is not None and bttl != ttl:
|
|
155
|
-
continue
|
|
156
|
-
|
|
157
|
-
cleared = await backend_instance.clear(pattern)
|
|
158
|
-
if cleared > 0:
|
|
159
|
-
total_cleared += cleared
|
|
160
|
-
logger.debug(f'Cleared {cleared} entries from {btype} backend (ttl={bttl})')
|
|
161
|
-
|
|
162
|
-
return total_cleared
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
async def async_cache_info(fn: Callable[..., Any]) -> CacheInfo:
|
|
166
|
-
"""Get cache statistics for an async decorated function.
|
|
167
|
-
|
|
168
|
-
Args:
|
|
169
|
-
fn: A function decorated with @async_cache
|
|
170
|
-
|
|
171
|
-
Returns
|
|
172
|
-
CacheInfo with hits, misses, and currsize
|
|
173
|
-
|
|
174
|
-
Raises
|
|
175
|
-
ValueError: If function is not decorated with @async_cache
|
|
176
|
-
"""
|
|
177
|
-
_get_meta(fn)
|
|
178
|
-
return await get_async_cache_info(fn)
|
cachu/backends/async_base.py
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
"""Async cache backend abstract base class.
|
|
2
|
-
"""
|
|
3
|
-
from abc import ABC, abstractmethod
|
|
4
|
-
from collections.abc import AsyncIterator
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class AsyncBackend(ABC):
|
|
9
|
-
"""Abstract base class for async cache backends.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
@abstractmethod
|
|
13
|
-
async def get(self, key: str) -> Any:
|
|
14
|
-
"""Get value by key. Returns NO_VALUE if not found.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
@abstractmethod
|
|
18
|
-
async def get_with_metadata(self, key: str) -> tuple[Any, float | None]:
|
|
19
|
-
"""Get value and creation timestamp. Returns (NO_VALUE, None) if not found.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
@abstractmethod
|
|
23
|
-
async def set(self, key: str, value: Any, ttl: int) -> None:
|
|
24
|
-
"""Set value with TTL in seconds.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
@abstractmethod
|
|
28
|
-
async def delete(self, key: str) -> None:
|
|
29
|
-
"""Delete value by key.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
@abstractmethod
|
|
33
|
-
async def clear(self, pattern: str | None = None) -> int:
|
|
34
|
-
"""Clear entries matching pattern. Returns count of cleared entries.
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
@abstractmethod
|
|
38
|
-
def keys(self, pattern: str | None = None) -> AsyncIterator[str]:
|
|
39
|
-
"""Iterate over keys matching pattern.
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
@abstractmethod
|
|
43
|
-
async def count(self, pattern: str | None = None) -> int:
|
|
44
|
-
"""Count keys matching pattern.
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
@abstractmethod
|
|
48
|
-
async def close(self) -> None:
|
|
49
|
-
"""Close the backend and release resources.
|
|
50
|
-
"""
|
cachu/backends/async_memory.py
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
"""Async memory cache backend implementation.
|
|
2
|
-
"""
|
|
3
|
-
import asyncio
|
|
4
|
-
import fnmatch
|
|
5
|
-
import pickle
|
|
6
|
-
import time
|
|
7
|
-
from collections.abc import AsyncIterator
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
from . import NO_VALUE
|
|
11
|
-
from .async_base import AsyncBackend
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class AsyncMemoryBackend(AsyncBackend):
|
|
15
|
-
"""Async in-memory cache backend using asyncio.Lock.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
def __init__(self) -> None:
|
|
19
|
-
self._cache: dict[str, tuple[bytes, float, float]] = {}
|
|
20
|
-
self._lock = asyncio.Lock()
|
|
21
|
-
|
|
22
|
-
async def get(self, key: str) -> Any:
|
|
23
|
-
"""Get value by key. Returns NO_VALUE if not found or expired.
|
|
24
|
-
"""
|
|
25
|
-
async with self._lock:
|
|
26
|
-
entry = self._cache.get(key)
|
|
27
|
-
if entry is None:
|
|
28
|
-
return NO_VALUE
|
|
29
|
-
|
|
30
|
-
pickled_value, created_at, expires_at = entry
|
|
31
|
-
if time.time() > expires_at:
|
|
32
|
-
del self._cache[key]
|
|
33
|
-
return NO_VALUE
|
|
34
|
-
|
|
35
|
-
return pickle.loads(pickled_value)
|
|
36
|
-
|
|
37
|
-
async def get_with_metadata(self, key: str) -> tuple[Any, float | None]:
|
|
38
|
-
"""Get value and creation timestamp. Returns (NO_VALUE, None) if not found.
|
|
39
|
-
"""
|
|
40
|
-
async with self._lock:
|
|
41
|
-
entry = self._cache.get(key)
|
|
42
|
-
if entry is None:
|
|
43
|
-
return NO_VALUE, None
|
|
44
|
-
|
|
45
|
-
pickled_value, created_at, expires_at = entry
|
|
46
|
-
if time.time() > expires_at:
|
|
47
|
-
del self._cache[key]
|
|
48
|
-
return NO_VALUE, None
|
|
49
|
-
|
|
50
|
-
return pickle.loads(pickled_value), created_at
|
|
51
|
-
|
|
52
|
-
async def set(self, key: str, value: Any, ttl: int) -> None:
|
|
53
|
-
"""Set value with TTL in seconds.
|
|
54
|
-
"""
|
|
55
|
-
now = time.time()
|
|
56
|
-
pickled_value = pickle.dumps(value)
|
|
57
|
-
async with self._lock:
|
|
58
|
-
self._cache[key] = (pickled_value, now, now + ttl)
|
|
59
|
-
|
|
60
|
-
async def delete(self, key: str) -> None:
|
|
61
|
-
"""Delete value by key.
|
|
62
|
-
"""
|
|
63
|
-
async with self._lock:
|
|
64
|
-
self._cache.pop(key, None)
|
|
65
|
-
|
|
66
|
-
async def clear(self, pattern: str | None = None) -> int:
|
|
67
|
-
"""Clear entries matching pattern. Returns count of cleared entries.
|
|
68
|
-
"""
|
|
69
|
-
async with self._lock:
|
|
70
|
-
if pattern is None:
|
|
71
|
-
count = len(self._cache)
|
|
72
|
-
self._cache.clear()
|
|
73
|
-
return count
|
|
74
|
-
|
|
75
|
-
keys_to_delete = [k for k in self._cache if fnmatch.fnmatch(k, pattern)]
|
|
76
|
-
for key in keys_to_delete:
|
|
77
|
-
del self._cache[key]
|
|
78
|
-
return len(keys_to_delete)
|
|
79
|
-
|
|
80
|
-
async def keys(self, pattern: str | None = None) -> AsyncIterator[str]:
|
|
81
|
-
"""Iterate over keys matching pattern.
|
|
82
|
-
"""
|
|
83
|
-
now = time.time()
|
|
84
|
-
async with self._lock:
|
|
85
|
-
all_keys = list(self._cache.keys())
|
|
86
|
-
|
|
87
|
-
for key in all_keys:
|
|
88
|
-
async with self._lock:
|
|
89
|
-
entry = self._cache.get(key)
|
|
90
|
-
if entry is None:
|
|
91
|
-
continue
|
|
92
|
-
_, _, expires_at = entry
|
|
93
|
-
if now > expires_at:
|
|
94
|
-
del self._cache[key]
|
|
95
|
-
continue
|
|
96
|
-
|
|
97
|
-
if pattern is None or fnmatch.fnmatch(key, pattern):
|
|
98
|
-
yield key
|
|
99
|
-
|
|
100
|
-
async def count(self, pattern: str | None = None) -> int:
|
|
101
|
-
"""Count keys matching pattern.
|
|
102
|
-
"""
|
|
103
|
-
count = 0
|
|
104
|
-
async for _ in self.keys(pattern):
|
|
105
|
-
count += 1
|
|
106
|
-
return count
|
|
107
|
-
|
|
108
|
-
async def close(self) -> None:
|
|
109
|
-
"""Close the backend (no-op for memory backend).
|
|
110
|
-
"""
|
|
111
|
-
pass
|