cachu 0.2.4__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/backends/redis.py CHANGED
@@ -6,7 +6,8 @@ import time
6
6
  from collections.abc import AsyncIterator, Iterator
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
9
- from . import NO_VALUE, AsyncBackend, Backend
9
+ from ..mutex import AsyncCacheMutex, AsyncRedisMutex, CacheMutex, RedisMutex
10
+ from . import NO_VALUE, Backend
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  import redis
@@ -25,7 +26,7 @@ def _get_redis_module() -> Any:
25
26
  return redis
26
27
  except ImportError as e:
27
28
  raise RuntimeError(
28
- "Redis support requires the 'redis' package. Install with: pip install cache[redis]"
29
+ "Redis support requires the 'redis' package. Install with: pip install cachu[redis]"
29
30
  ) from e
30
31
 
31
32
 
@@ -42,16 +43,6 @@ def _get_async_redis_module() -> Any:
42
43
  ) from e
43
44
 
44
45
 
45
- async def get_async_redis_client(url: str) -> 'aioredis.Redis':
46
- """Create an async Redis client from URL.
47
-
48
- Args:
49
- url: Redis URL (e.g., 'redis://localhost:6379/0')
50
- """
51
- aioredis = _get_async_redis_module()
52
- return aioredis.from_url(url)
53
-
54
-
55
46
  def get_redis_client(url: str) -> 'redis.Redis':
56
47
  """Create a Redis client from URL.
57
48
 
@@ -79,20 +70,32 @@ def _unpack_value(data: bytes) -> tuple[Any, float]:
79
70
 
80
71
 
81
72
  class RedisBackend(Backend):
82
- """Redis cache backend.
73
+ """Unified Redis cache backend with both sync and async interfaces.
83
74
  """
84
75
 
85
- def __init__(self, url: str) -> None:
76
+ def __init__(self, url: str, lock_timeout: float = 10.0) -> None:
86
77
  self._url = url
87
- self._client: redis.Redis | None = None
78
+ self._lock_timeout = lock_timeout
79
+ self._sync_client: redis.Redis | None = None
80
+ self._async_client: aioredis.Redis | None = None
88
81
 
89
82
  @property
90
83
  def client(self) -> 'redis.Redis':
91
- """Lazy-load Redis client.
84
+ """Lazy-load sync Redis client.
85
+ """
86
+ if self._sync_client is None:
87
+ self._sync_client = get_redis_client(self._url)
88
+ return self._sync_client
89
+
90
+ def _get_async_client(self) -> 'aioredis.Redis':
91
+ """Lazy-load async Redis client (from_url is NOT async).
92
92
  """
93
- if self._client is None:
94
- self._client = get_redis_client(self._url)
95
- return self._client
93
+ if self._async_client is None:
94
+ aioredis = _get_async_redis_module()
95
+ self._async_client = aioredis.from_url(self._url)
96
+ return self._async_client
97
+
98
+ # ===== Sync interface =====
96
99
 
97
100
  def get(self, key: str) -> Any:
98
101
  """Get value by key. Returns NO_VALUE if not found.
@@ -148,67 +151,51 @@ class RedisBackend(Backend):
148
151
  """
149
152
  return sum(1 for _ in self.keys(pattern))
150
153
 
151
- def close(self) -> None:
152
- """Close the Redis connection.
154
+ def get_mutex(self, key: str) -> CacheMutex:
155
+ """Get a mutex for dogpile prevention on the given key.
153
156
  """
154
- if self._client is not None:
155
- self._client.close()
156
- self._client = None
157
-
158
-
159
- class AsyncRedisBackend(AsyncBackend):
160
- """Async Redis cache backend using redis.asyncio.
161
- """
157
+ return RedisMutex(self.client, f'lock:{key}', self._lock_timeout)
162
158
 
163
- def __init__(self, url: str) -> None:
164
- self._url = url
165
- self._client: aioredis.Redis | None = None
166
-
167
- async def _get_client(self) -> 'aioredis.Redis':
168
- """Lazy-load async Redis client.
169
- """
170
- if self._client is None:
171
- self._client = await get_async_redis_client(self._url)
172
- return self._client
159
+ # ===== Async interface =====
173
160
 
174
- async def get(self, key: str) -> Any:
175
- """Get value by key. Returns NO_VALUE if not found.
161
+ async def aget(self, key: str) -> Any:
162
+ """Async get value by key. Returns NO_VALUE if not found.
176
163
  """
177
- client = await self._get_client()
164
+ client = self._get_async_client()
178
165
  data = await client.get(key)
179
166
  if data is None:
180
167
  return NO_VALUE
181
168
  value, _ = _unpack_value(data)
182
169
  return value
183
170
 
184
- async def get_with_metadata(self, key: str) -> tuple[Any, float | None]:
185
- """Get value and creation timestamp. Returns (NO_VALUE, None) if not found.
171
+ async def aget_with_metadata(self, key: str) -> tuple[Any, float | None]:
172
+ """Async get value and creation timestamp. Returns (NO_VALUE, None) if not found.
186
173
  """
187
- client = await self._get_client()
174
+ client = self._get_async_client()
188
175
  data = await client.get(key)
189
176
  if data is None:
190
177
  return NO_VALUE, None
191
178
  value, created_at = _unpack_value(data)
192
179
  return value, created_at
193
180
 
194
- async def set(self, key: str, value: Any, ttl: int) -> None:
195
- """Set value with TTL in seconds.
181
+ async def aset(self, key: str, value: Any, ttl: int) -> None:
182
+ """Async set value with TTL in seconds.
196
183
  """
197
- client = await self._get_client()
184
+ client = self._get_async_client()
198
185
  now = time.time()
199
186
  packed = _pack_value(value, now)
200
187
  await client.setex(key, ttl, packed)
201
188
 
202
- async def delete(self, key: str) -> None:
203
- """Delete value by key.
189
+ async def adelete(self, key: str) -> None:
190
+ """Async delete value by key.
204
191
  """
205
- client = await self._get_client()
192
+ client = self._get_async_client()
206
193
  await client.delete(key)
207
194
 
208
- async def clear(self, pattern: str | None = None) -> int:
209
- """Clear entries matching pattern. Returns count of cleared entries.
195
+ async def aclear(self, pattern: str | None = None) -> int:
196
+ """Async clear entries matching pattern. Returns count of cleared entries.
210
197
  """
211
- client = await self._get_client()
198
+ client = self._get_async_client()
212
199
  if pattern is None:
213
200
  pattern = '*'
214
201
 
@@ -218,25 +205,59 @@ class AsyncRedisBackend(AsyncBackend):
218
205
  count += 1
219
206
  return count
220
207
 
221
- async def keys(self, pattern: str | None = None) -> AsyncIterator[str]:
222
- """Iterate over keys matching pattern.
208
+ async def akeys(self, pattern: str | None = None) -> AsyncIterator[str]:
209
+ """Async iterate over keys matching pattern.
223
210
  """
224
- client = await self._get_client()
211
+ client = self._get_async_client()
225
212
  redis_pattern = pattern or '*'
226
213
  async for key in client.scan_iter(match=redis_pattern):
227
214
  yield key.decode() if isinstance(key, bytes) else key
228
215
 
229
- async def count(self, pattern: str | None = None) -> int:
230
- """Count keys matching pattern.
216
+ async def acount(self, pattern: str | None = None) -> int:
217
+ """Async count keys matching pattern.
231
218
  """
232
219
  count = 0
233
- async for _ in self.keys(pattern):
220
+ async for _ in self.akeys(pattern):
234
221
  count += 1
235
222
  return count
236
223
 
237
- async def close(self) -> None:
238
- """Close the Redis connection.
224
+ def get_async_mutex(self, key: str) -> AsyncCacheMutex:
225
+ """Get an async mutex for dogpile prevention on the given key.
226
+ """
227
+ return AsyncRedisMutex(self._get_async_client(), f'lock:{key}', self._lock_timeout)
228
+
229
+ # ===== Lifecycle =====
230
+
231
+ def _close_sync_client(self) -> None:
232
+ """Close sync client if open.
233
+ """
234
+ if self._sync_client is not None:
235
+ client = self._sync_client
236
+ self._sync_client = None
237
+ client.close()
238
+
239
+ def _close_async_client_sync(self) -> None:
240
+ """Forcefully close async client from sync context.
241
+ """
242
+ if self._async_client is not None:
243
+ client = self._async_client
244
+ self._async_client = None
245
+ try:
246
+ client.close()
247
+ except Exception:
248
+ pass
249
+
250
+ def close(self) -> None:
251
+ """Close all backend resources from sync context.
252
+ """
253
+ self._close_sync_client()
254
+ self._close_async_client_sync()
255
+
256
+ async def aclose(self) -> None:
257
+ """Close all backend resources from async context.
239
258
  """
240
- if self._client is not None:
241
- await self._client.close()
242
- self._client = None
259
+ if self._async_client is not None:
260
+ client = self._async_client
261
+ self._async_client = None
262
+ await client.aclose()
263
+ self._close_sync_client()