cachu 0.1.3__py3-none-any.whl → 0.2.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.
cachu/__init__.py CHANGED
@@ -1,7 +1,13 @@
1
1
  """Flexible caching library with support for memory, file, and Redis backends.
2
2
  """
3
- __version__ = '0.1.3'
3
+ __version__ = '0.2.0'
4
4
 
5
+ from .async_decorator import async_cache, clear_async_backends
6
+ from .async_decorator import get_async_backend, get_async_cache_info
7
+ from .async_operations import async_cache_clear, async_cache_delete
8
+ from .async_operations import async_cache_get, async_cache_info
9
+ from .async_operations import async_cache_set
10
+ from .backends import AsyncBackend, Backend
5
11
  from .backends.redis import get_redis_client
6
12
  from .config import configure, disable, enable, get_all_configs, get_config
7
13
  from .config import is_disabled
@@ -24,4 +30,15 @@ __all__ = [
24
30
  'cache_info',
25
31
  'get_backend',
26
32
  'get_redis_client',
33
+ 'Backend',
34
+ 'AsyncBackend',
35
+ 'async_cache',
36
+ 'async_cache_get',
37
+ 'async_cache_set',
38
+ 'async_cache_delete',
39
+ 'async_cache_clear',
40
+ 'async_cache_info',
41
+ 'get_async_backend',
42
+ 'get_async_cache_info',
43
+ 'clear_async_backends',
27
44
  ]
@@ -0,0 +1,261 @@
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
+ ttl: int = 300,
70
+ ) -> AsyncBackend:
71
+ """Get an async backend instance.
72
+
73
+ Args:
74
+ backend_type: 'memory', 'file', or 'redis'. Uses config default if None.
75
+ package: Package name. Auto-detected if None.
76
+ ttl: TTL in seconds (used for backend separation).
77
+ """
78
+ if package is None:
79
+ package = _get_caller_package()
80
+
81
+ if backend_type is None:
82
+ cfg = get_config(package)
83
+ backend_type = cfg.backend
84
+
85
+ return await _get_async_backend(package, backend_type, ttl)
86
+
87
+
88
+ def async_cache(
89
+ ttl: int = 300,
90
+ backend: str | None = None,
91
+ tag: str = '',
92
+ exclude: set[str] | None = None,
93
+ cache_if: Callable[[Any], bool] | None = None,
94
+ validate: Callable[[CacheEntry], bool] | None = None,
95
+ package: str | None = None,
96
+ ) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
97
+ """Async cache decorator with configurable backend and behavior.
98
+
99
+ Args:
100
+ ttl: Time-to-live in seconds (default: 300)
101
+ backend: Backend type ('memory', 'file', 'redis'). Uses config default if None.
102
+ tag: Tag for grouping related cache entries
103
+ exclude: Parameter names to exclude from cache key
104
+ cache_if: Function to determine if result should be cached.
105
+ Called with result value, caches if returns True.
106
+ validate: Function to validate cached entries before returning.
107
+ Called with CacheEntry, returns False to recompute.
108
+ package: Package name for config isolation. Auto-detected if None.
109
+
110
+ Per-call control via reserved kwargs (not passed to function):
111
+ _skip_cache: If True, bypass cache completely for this call
112
+ _overwrite_cache: If True, execute function and overwrite cached value
113
+
114
+ Example:
115
+ @async_cache(ttl=300, tag='users')
116
+ async def get_user(user_id: int) -> dict:
117
+ return await fetch_user(user_id)
118
+
119
+ # Normal call
120
+ user = await get_user(123)
121
+
122
+ # Skip cache
123
+ user = await get_user(123, _skip_cache=True)
124
+
125
+ # Force refresh
126
+ user = await get_user(123, _overwrite_cache=True)
127
+ """
128
+ resolved_package = package if package is not None else _get_caller_package()
129
+
130
+ if backend is None:
131
+ cfg = get_config(resolved_package)
132
+ resolved_backend = cfg.backend
133
+ else:
134
+ resolved_backend = backend
135
+
136
+ def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
137
+ key_generator = make_key_generator(fn, tag, exclude)
138
+
139
+ meta = CacheMeta(
140
+ ttl=ttl,
141
+ backend=resolved_backend,
142
+ tag=tag,
143
+ exclude=exclude or set(),
144
+ cache_if=cache_if,
145
+ validate=validate,
146
+ package=resolved_package,
147
+ key_generator=key_generator,
148
+ )
149
+
150
+ @wraps(fn)
151
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
152
+ skip_cache = kwargs.pop('_skip_cache', False)
153
+ overwrite_cache = kwargs.pop('_overwrite_cache', False)
154
+
155
+ if is_disabled() or skip_cache:
156
+ return await fn(*args, **kwargs)
157
+
158
+ backend_instance = await _get_async_backend(resolved_package, resolved_backend, ttl)
159
+ cfg = get_config(resolved_package)
160
+
161
+ base_key = key_generator(*args, **kwargs)
162
+ cache_key = mangle_key(base_key, cfg.key_prefix, ttl)
163
+
164
+ if not overwrite_cache:
165
+ value, created_at = await backend_instance.get_with_metadata(cache_key)
166
+
167
+ if value is not NO_VALUE:
168
+ if validate is not None and created_at is not None:
169
+ entry = CacheEntry(
170
+ value=value,
171
+ created_at=created_at,
172
+ age=time.time() - created_at,
173
+ )
174
+ if not validate(entry):
175
+ logger.debug(f'Cache validation failed for {fn.__name__}')
176
+ else:
177
+ await _record_async_hit(wrapper)
178
+ return value
179
+ else:
180
+ await _record_async_hit(wrapper)
181
+ return value
182
+
183
+ await _record_async_miss(wrapper)
184
+ result = await fn(*args, **kwargs)
185
+
186
+ should_cache = cache_if is None or cache_if(result)
187
+
188
+ if should_cache:
189
+ await backend_instance.set(cache_key, result, ttl)
190
+ logger.debug(f'Cached {fn.__name__} with key {cache_key}')
191
+
192
+ return result
193
+
194
+ wrapper._cache_meta = meta # type: ignore
195
+ wrapper._cache_key_generator = key_generator # type: ignore
196
+
197
+ return wrapper
198
+
199
+ return decorator
200
+
201
+
202
+ async def _record_async_hit(fn: Callable[..., Any]) -> None:
203
+ """Record a cache hit for the async function.
204
+ """
205
+ fn_id = id(fn)
206
+ async with _async_stats_lock:
207
+ hits, misses = _async_stats.get(fn_id, (0, 0))
208
+ _async_stats[fn_id] = (hits + 1, misses)
209
+
210
+
211
+ async def _record_async_miss(fn: Callable[..., Any]) -> None:
212
+ """Record a cache miss for the async function.
213
+ """
214
+ fn_id = id(fn)
215
+ async with _async_stats_lock:
216
+ hits, misses = _async_stats.get(fn_id, (0, 0))
217
+ _async_stats[fn_id] = (hits, misses + 1)
218
+
219
+
220
+ async def get_async_cache_info(fn: Callable[..., Any]) -> CacheInfo:
221
+ """Get cache statistics for an async decorated function.
222
+
223
+ Args:
224
+ fn: A function decorated with @async_cache
225
+
226
+ Returns
227
+ CacheInfo with hits, misses, and currsize
228
+ """
229
+ fn_id = id(fn)
230
+
231
+ async with _async_stats_lock:
232
+ hits, misses = _async_stats.get(fn_id, (0, 0))
233
+
234
+ meta = getattr(fn, '_cache_meta', None)
235
+ if meta is None:
236
+ return CacheInfo(hits=hits, misses=misses, currsize=0)
237
+
238
+ backend_instance = await _get_async_backend(meta.package, meta.backend, meta.ttl)
239
+ cfg = get_config(meta.package)
240
+
241
+ fn_name = getattr(fn, '__wrapped__', fn).__name__
242
+ pattern = f'*:{cfg.key_prefix}{fn_name}|*'
243
+
244
+ currsize = await backend_instance.count(pattern)
245
+
246
+ return CacheInfo(hits=hits, misses=misses, currsize=currsize)
247
+
248
+
249
+ async def clear_async_backends(package: str | None = None) -> None:
250
+ """Clear all async backend instances for a package. Primarily for testing.
251
+ """
252
+ async with _async_backends_lock:
253
+ if package is None:
254
+ for backend in _async_backends.values():
255
+ await backend.close()
256
+ _async_backends.clear()
257
+ else:
258
+ keys_to_delete = [k for k in _async_backends if k[0] == package]
259
+ for key in keys_to_delete:
260
+ await _async_backends[key].close()
261
+ del _async_backends[key]
@@ -0,0 +1,178 @@
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)
@@ -1,8 +1,8 @@
1
1
  """Cache backend implementations.
2
2
  """
3
3
  from abc import ABC, abstractmethod
4
- from typing import Any
5
4
  from collections.abc import Iterator
5
+ from typing import Any
6
6
 
7
7
  NO_VALUE = object()
8
8
 
@@ -45,3 +45,8 @@ class Backend(ABC):
45
45
  def count(self, pattern: str | None = None) -> int:
46
46
  """Count keys matching pattern.
47
47
  """
48
+
49
+
50
+ from .async_base import AsyncBackend
51
+
52
+ __all__ = ['Backend', 'AsyncBackend', 'NO_VALUE']
@@ -0,0 +1,50 @@
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
+ """
@@ -0,0 +1,111 @@
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