simple-dep-cache 0.2.0__tar.gz → 0.3.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.
Files changed (37) hide show
  1. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.github/workflows/test.yml +6 -0
  2. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/CHANGELOG.md +21 -0
  3. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/PKG-INFO +31 -1
  4. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/README.md +30 -0
  5. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/decorators.py +81 -23
  6. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/events.py +42 -16
  7. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/manager.py +37 -22
  8. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/types.py +5 -1
  9. simple_dep_cache-0.3.0/tests/test_async_callbacks.py +111 -0
  10. simple_dep_cache-0.3.0/tests/test_silent_backend_errors.py +207 -0
  11. simple_dep_cache-0.2.0/.claude/hehe +0 -0
  12. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.github/workflows/publish-pypi.yaml +0 -0
  13. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.github/workflows/static-check.yml +0 -0
  14. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.gitignore +0 -0
  15. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.pre-commit-config.yaml +0 -0
  16. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.python-version +0 -0
  17. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.ruff.toml +0 -0
  18. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/docker-compose.yml +0 -0
  19. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/pyproject.toml +0 -0
  20. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/__init__.py +0 -0
  21. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/backends.py +0 -0
  22. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/config.py +0 -0
  23. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/context.py +0 -0
  24. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/factories.py +0 -0
  25. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/fakes.py +0 -0
  26. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/redis_backends.py +0 -0
  27. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/utils.py +0 -0
  28. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/__init__.py +0 -0
  29. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_cache_manager.py +0 -0
  30. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_config.py +0 -0
  31. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_context.py +0 -0
  32. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_decorators.py +0 -0
  33. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_end_to_end_redis_simple.py +0 -0
  34. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_fakes.py +0 -0
  35. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_redis_backends.py +0 -0
  36. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_types.py +0 -0
  37. {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/uv.lock +0 -0
@@ -10,6 +10,11 @@ jobs:
10
10
  test:
11
11
  runs-on: ubuntu-latest
12
12
 
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
17
+
13
18
  steps:
14
19
  - uses: actions/checkout@v5
15
20
 
@@ -17,6 +22,7 @@ jobs:
17
22
  uses: astral-sh/setup-uv@v6
18
23
  with:
19
24
  version: "latest"
25
+ python-version: ${{ matrix.python-version }}
20
26
 
21
27
  - name: Install dependencies
22
28
  run: uv sync --all-extras
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## v0.3.0 - 2026-06-23
6
+
7
+ ### Added
8
+
9
+ - **Async event callbacks**: event callbacks registered via `on_event` /
10
+ `on_all_events` may now be coroutine functions. The async cache methods
11
+ (`aset`, `aget`, `adelete`, `aclear`, `ainvalidate_dependency`) dispatch to
12
+ both sync and async callbacks, awaiting the async ones.
13
+ - New `EventEmitter.aemit()` awaits async callbacks; `emit()` runs only sync
14
+ callbacks (it never leaves a coroutine un-awaited).
15
+ - New `EventEmitter.has_async_callbacks(event_type)`.
16
+ - Callback type hints widened to `CacheCallback = Callable[[CacheEvent], Any]`.
17
+
18
+ ### Changed
19
+
20
+ - A **synchronous** cache operation (`set`/`get`/`delete`/`clear`/
21
+ `invalidate_dependency`) now raises `RuntimeError` if an async callback is
22
+ registered for the emitted event, instead of silently dropping it. Use the
23
+ async (`a`-prefixed) methods to dispatch async callbacks. Sync callbacks are
24
+ unaffected.
25
+
5
26
  ## v0.1.3 - 2025-09-19
6
27
 
7
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple-dep-cache
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Redis-based caching library with intelligent dependency tracking for Python applications
5
5
  Project-URL: Homepage, https://github.com/tintty1/simple-dep-cache
6
6
  Project-URL: Repository, https://github.com/tintty1/simple-dep-cache
@@ -543,6 +543,36 @@ async def async_function(x):
543
543
 
544
544
  Callback exceptions are caught and logged.
545
545
 
546
+ #### Event callbacks (sync & async)
547
+
548
+ Beyond the decorator `callback`, you can subscribe to manager-level events
549
+ (`SET`, `HIT`, `MISS`, `DELETE`, `INVALIDATE`, `CLEAR`). Callbacks may be sync
550
+ **or** async:
551
+
552
+ ```python
553
+ from simple_dep_cache import CacheEventType, get_or_create_cache_manager
554
+
555
+ cache = get_or_create_cache_manager()
556
+
557
+ def on_invalidate(event): # sync callback
558
+ log.info("invalidated %s (%s entries)", event.key, event.count)
559
+
560
+ async def on_invalidate_async(event): # async callback
561
+ await bus.publish("cache.invalidate", {"key": event.key})
562
+
563
+ cache.on_event(CacheEventType.INVALIDATE, on_invalidate)
564
+ cache.on_event(CacheEventType.INVALIDATE, on_invalidate_async)
565
+ ```
566
+
567
+ - Async callbacks are awaited by the **async** cache methods (`aset`, `aget`,
568
+ `adelete`, `aclear`, `ainvalidate_dependency`) via `EventEmitter.aemit()`.
569
+ - The **sync** methods only dispatch sync callbacks. If an async callback is
570
+ registered for an event a sync method emits, that method raises
571
+ `RuntimeError` (a sync context can't await it) — use the async method, or a
572
+ sync callback.
573
+ - A single sync callback also runs from the async methods, so it's the simplest
574
+ choice when you don't need to `await` inside the callback.
575
+
546
576
  **Decorators:**
547
577
 
548
578
  - `@cache_with_deps(name, ttl, dependencies, cache_exception_types, callback)` - Works for both sync and async functions
@@ -521,6 +521,36 @@ async def async_function(x):
521
521
 
522
522
  Callback exceptions are caught and logged.
523
523
 
524
+ #### Event callbacks (sync & async)
525
+
526
+ Beyond the decorator `callback`, you can subscribe to manager-level events
527
+ (`SET`, `HIT`, `MISS`, `DELETE`, `INVALIDATE`, `CLEAR`). Callbacks may be sync
528
+ **or** async:
529
+
530
+ ```python
531
+ from simple_dep_cache import CacheEventType, get_or_create_cache_manager
532
+
533
+ cache = get_or_create_cache_manager()
534
+
535
+ def on_invalidate(event): # sync callback
536
+ log.info("invalidated %s (%s entries)", event.key, event.count)
537
+
538
+ async def on_invalidate_async(event): # async callback
539
+ await bus.publish("cache.invalidate", {"key": event.key})
540
+
541
+ cache.on_event(CacheEventType.INVALIDATE, on_invalidate)
542
+ cache.on_event(CacheEventType.INVALIDATE, on_invalidate_async)
543
+ ```
544
+
545
+ - Async callbacks are awaited by the **async** cache methods (`aset`, `aget`,
546
+ `adelete`, `aclear`, `ainvalidate_dependency`) via `EventEmitter.aemit()`.
547
+ - The **sync** methods only dispatch sync callbacks. If an async callback is
548
+ registered for an event a sync method emits, that method raises
549
+ `RuntimeError` (a sync context can't await it) — use the async method, or a
550
+ sync callback.
551
+ - A single sync callback also runs from the async methods, so it's the simplest
552
+ choice when you don't need to `await` inside the callback.
553
+
524
554
  **Decorators:**
525
555
 
526
556
  - `@cache_with_deps(name, ttl, dependencies, cache_exception_types, callback)` - Works for both sync and async functions
@@ -1,5 +1,5 @@
1
- import asyncio
2
1
  import hashlib
2
+ import inspect
3
3
  import logging
4
4
  from collections.abc import Callable
5
5
  from functools import wraps
@@ -108,7 +108,7 @@ def _validate_callback_compatibility(
108
108
  if callback is None:
109
109
  return None
110
110
 
111
- callback_is_async = asyncio.iscoroutinefunction(callback)
111
+ callback_is_async = inspect.iscoroutinefunction(callback)
112
112
 
113
113
  if is_async_function and not callback_is_async:
114
114
  # Sync callback with async function is fine
@@ -149,6 +149,32 @@ def _handle_callback_error(error: Exception, cache_manager: CacheManager, contex
149
149
  pass
150
150
 
151
151
 
152
+ def _handle_backend_error(operation: str, func_name: str, error: Exception, silent: bool) -> None:
153
+ """Handle backend errors by logging or re-raising based on silent flag."""
154
+ if not silent:
155
+ raise
156
+ logger = logging.getLogger(__name__)
157
+ logger.warning("Backend error during %s for %s: %s", operation, func_name, error, exc_info=True)
158
+
159
+
160
+ def _safe_backend_op(op: Callable, silent: bool, func_name: str, operation: str) -> Any:
161
+ """Execute a backend operation with optional error silencing."""
162
+ try:
163
+ return op()
164
+ except Exception as e:
165
+ _handle_backend_error(operation, func_name, e, silent)
166
+ return None
167
+
168
+
169
+ async def _safe_backend_op_async(op: Callable, silent: bool, func_name: str, operation: str) -> Any:
170
+ """Execute an async backend operation with optional error silencing."""
171
+ try:
172
+ return await op()
173
+ except Exception as e:
174
+ _handle_backend_error(operation, func_name, e, silent)
175
+ return None
176
+
177
+
152
178
  def _cache_result_or_exception_sync(
153
179
  cache_manager: CacheManager,
154
180
  cache_key: str,
@@ -211,6 +237,7 @@ def cache_with_deps(
211
237
  dependencies: set | None = None,
212
238
  cache_exception_types: list[type[Exception]] | None = None,
213
239
  callback: Callable | None = None,
240
+ silent_backend_errors: bool = False,
214
241
  ) -> Callable:
215
242
  """
216
243
  Decorator for caching function results with dependency tracking.
@@ -226,10 +253,13 @@ def cache_with_deps(
226
253
  callback: Callback function invoked on cache hit or miss.
227
254
  Called with keyword arguments: func, cache_manager, args, kwargs, is_hit, cached_result
228
255
  is_hit=True for cache hits, False for cache misses (optional)
256
+ silent_backend_errors: If True, silently log backend errors (e.g., Redis connection errors)
257
+ instead of raising them. The decorated function will execute normally when backend
258
+ errors occur. Defaults to False (optional)
229
259
  """
230
260
 
231
261
  def decorator(func: Callable) -> Callable:
232
- if asyncio.iscoroutinefunction(func):
262
+ if inspect.iscoroutinefunction(func):
233
263
 
234
264
  @wraps(func)
235
265
  async def async_wrapper(*args, **kwargs):
@@ -244,14 +274,21 @@ def cache_with_deps(
244
274
 
245
275
  cache_key = _generate_cache_key(func, args, kwargs)
246
276
 
247
- cached_result = await active_cache_manager.aget(cache_key)
277
+ # Try to get from cache with optional error silencing
278
+ cached_result = await _safe_backend_op_async(
279
+ lambda: active_cache_manager.aget(cache_key),
280
+ silent_backend_errors,
281
+ func.__qualname__,
282
+ "cache get",
283
+ )
284
+
248
285
  if cached_result is not None:
249
286
  cache_hit_result = _handle_cache_hit(cached_result)
250
287
  if cache_hit_result is not None:
251
288
  # Invoke callback for cache hit
252
289
  if valid_callback:
253
290
  try:
254
- if asyncio.iscoroutinefunction(valid_callback):
291
+ if inspect.iscoroutinefunction(valid_callback):
255
292
  await valid_callback(
256
293
  func=func,
257
294
  cache_manager=active_cache_manager,
@@ -284,20 +321,27 @@ def cache_with_deps(
284
321
  finally:
285
322
  current_deps = get_current_dependencies()
286
323
  effective_ttl = get_cache_ttl()
287
- await _cache_result_or_exception_async(
288
- active_cache_manager,
289
- cache_key,
290
- result,
291
- exception,
292
- current_deps,
293
- effective_ttl,
294
- cache_exception_types,
324
+
325
+ # Try to set cache with optional error silencing
326
+ await _safe_backend_op_async(
327
+ lambda: _cache_result_or_exception_async(
328
+ active_cache_manager,
329
+ cache_key,
330
+ result,
331
+ exception,
332
+ current_deps,
333
+ effective_ttl,
334
+ cache_exception_types,
335
+ ),
336
+ silent_backend_errors,
337
+ func.__qualname__,
338
+ "cache set",
295
339
  )
296
340
 
297
341
  # Invoke callback for cache miss
298
342
  if valid_callback:
299
343
  try:
300
- if asyncio.iscoroutinefunction(valid_callback):
344
+ if inspect.iscoroutinefunction(valid_callback):
301
345
  await valid_callback(
302
346
  func=func,
303
347
  cache_manager=active_cache_manager,
@@ -338,7 +382,14 @@ def cache_with_deps(
338
382
 
339
383
  cache_key = _generate_cache_key(func, args, kwargs)
340
384
 
341
- cached_result = active_cache_manager.get(cache_key)
385
+ # Try to get from cache with optional error silencing
386
+ cached_result = _safe_backend_op(
387
+ lambda: active_cache_manager.get(cache_key),
388
+ silent_backend_errors,
389
+ func.__qualname__,
390
+ "cache get",
391
+ )
392
+
342
393
  if cached_result is not None:
343
394
  cache_hit_result = _handle_cache_hit(cached_result)
344
395
  if cache_hit_result is not None:
@@ -368,14 +419,21 @@ def cache_with_deps(
368
419
  finally:
369
420
  current_deps = get_current_dependencies()
370
421
  effective_ttl = get_cache_ttl()
371
- _cache_result_or_exception_sync(
372
- active_cache_manager,
373
- cache_key,
374
- result,
375
- exception,
376
- current_deps,
377
- effective_ttl,
378
- cache_exception_types,
422
+
423
+ # Try to set cache with optional error silencing
424
+ _safe_backend_op(
425
+ lambda: _cache_result_or_exception_sync(
426
+ active_cache_manager,
427
+ cache_key,
428
+ result,
429
+ exception,
430
+ current_deps,
431
+ effective_ttl,
432
+ cache_exception_types,
433
+ ),
434
+ silent_backend_errors,
435
+ func.__qualname__,
436
+ "cache set",
379
437
  )
380
438
 
381
439
  # Invoke callback for cache miss
@@ -1,8 +1,9 @@
1
1
  import logging
2
2
  import time
3
- from collections.abc import Callable
3
+ from collections.abc import Callable, Iterator
4
4
  from dataclasses import dataclass
5
5
  from enum import Enum
6
+ from inspect import iscoroutinefunction
6
7
  from typing import Any
7
8
 
8
9
  from .config import ConfigBase
@@ -38,6 +39,9 @@ class CacheEvent:
38
39
  self.timestamp = time.time()
39
40
 
40
41
 
42
+ CacheCallback = Callable[[CacheEvent], Any]
43
+
44
+
41
45
  class EventEmitter:
42
46
  """Simple event emitter for cache events."""
43
47
 
@@ -48,45 +52,67 @@ class EventEmitter:
48
52
  }
49
53
  self._global_callbacks: list[Callable] = []
50
54
 
51
- def on(self, event_type: CacheEventType, callback: Callable[[CacheEvent], None]) -> None:
55
+ def on(self, event_type: CacheEventType, callback: CacheCallback) -> None:
52
56
  """Register a callback for a specific event type."""
53
57
  self._callbacks[event_type].append(callback)
54
58
 
55
- def on_all(self, callback: Callable[[CacheEvent], None]) -> None:
59
+ def on_all(self, callback: CacheCallback) -> None:
56
60
  """Register a callback for all event types."""
57
61
  self._global_callbacks.append(callback)
58
62
 
59
- def off(self, event_type: CacheEventType, callback: Callable[[CacheEvent], None]) -> bool:
63
+ def off(self, event_type: CacheEventType, callback: CacheCallback) -> bool:
60
64
  """Unregister a callback for a specific event type."""
61
65
  if callback in self._callbacks[event_type]:
62
66
  self._callbacks[event_type].remove(callback)
63
67
  return True
64
68
  return False
65
69
 
66
- def off_all(self, callback: Callable[[CacheEvent], None]) -> bool:
70
+ def off_all(self, callback: CacheCallback) -> bool:
67
71
  """Unregister a callback from all events."""
68
72
  if callback in self._global_callbacks:
69
73
  self._global_callbacks.remove(callback)
70
74
  return True
71
75
  return False
72
76
 
77
+ def _iter_callbacks(self, event_type: CacheEventType) -> Iterator[Callable]:
78
+ """Yield specific-then-global callbacks for an event type."""
79
+ yield from self._callbacks[event_type]
80
+ yield from self._global_callbacks
81
+
82
+ def has_async_callbacks(self, event_type: CacheEventType) -> bool:
83
+ """True if any registered callback for this event type is a coroutine function."""
84
+ return any(iscoroutinefunction(cb) for cb in self._iter_callbacks(event_type))
85
+
86
+ def _handle_callback_error(self, error: Exception) -> None:
87
+ if not self.config.callback_error_silent:
88
+ logger.exception("Error in cache event callback: %s", error)
89
+
73
90
  def emit(self, event: CacheEvent) -> None:
74
- """Emit an event to all registered callbacks."""
75
- # Call specific event callbacks
76
- for callback in self._callbacks[event.event_type]:
91
+ """Dispatch the event to all *sync* callbacks.
92
+
93
+ Async callbacks are skipped here; they are dispatched by :meth:`aemit`
94
+ from the manager's async methods.
95
+ """
96
+ for callback in self._iter_callbacks(event.event_type):
97
+ if iscoroutinefunction(callback):
98
+ continue
77
99
  try:
78
100
  callback(event)
79
101
  except Exception as e:
80
- if not self.config.callback_error_silent:
81
- logger.exception("Error in cache event callback: %s", e)
102
+ self._handle_callback_error(e)
103
+
104
+ async def aemit(self, event: CacheEvent) -> None:
105
+ """Dispatch the event to all *async* callbacks, awaiting each.
82
106
 
83
- # Call global callbacks
84
- for callback in self._global_callbacks:
107
+ Sync callbacks are skipped here; they are dispatched by :meth:`emit`.
108
+ """
109
+ for callback in self._iter_callbacks(event.event_type):
110
+ if not iscoroutinefunction(callback):
111
+ continue
85
112
  try:
86
- callback(event)
113
+ await callback(event)
87
114
  except Exception as e:
88
- if not self.config.callback_error_silent:
89
- logger.exception("Error in cache event callback: %s", e)
115
+ self._handle_callback_error(e)
90
116
 
91
117
  def clear_all(self) -> None:
92
118
  """Clear all callbacks."""
@@ -148,7 +174,7 @@ class StatsCollector:
148
174
  self.start_time = time.time()
149
175
 
150
176
 
151
- def create_logger_callback(name: str = "cache") -> Callable[[CacheEvent], None]:
177
+ def create_logger_callback(name: str = "cache") -> CacheCallback:
152
178
  """Create a callback that logs cache events."""
153
179
 
154
180
  def logger_callback(event: CacheEvent) -> None:
@@ -2,12 +2,11 @@ import builtins
2
2
  import threading
3
3
  import time
4
4
  import warnings
5
- from collections.abc import Callable
6
5
  from typing import Optional
7
6
 
8
7
  from .backends import AsyncCacheBackend, CacheBackend
9
8
  from .config import ConfigBase, RedisConfig
10
- from .events import CacheEvent, CacheEventType, EventEmitter
9
+ from .events import CacheCallback, CacheEvent, CacheEventType, EventEmitter
11
10
  from .types import CacheValue
12
11
 
13
12
  _manager_lock = threading.Lock()
@@ -115,6 +114,26 @@ class CacheManager:
115
114
  else:
116
115
  raise RuntimeError("No backend available.")
117
116
 
117
+ def _emit(self, event: CacheEvent) -> None:
118
+ """Dispatch an event from a synchronous operation.
119
+
120
+ Sync operations can't await, so an async callback registered for this
121
+ event has no way to run -- that's a misconfiguration, so we raise rather
122
+ than silently drop it. Use the async (a*) methods for async callbacks.
123
+ """
124
+ if self.events.has_async_callbacks(event.event_type):
125
+ raise RuntimeError(
126
+ f"An async callback is registered for {event.event_type}, but it was "
127
+ f"triggered by a synchronous cache operation and cannot be awaited. "
128
+ f"Use the async ('a'-prefixed) methods, or register a sync callback."
129
+ )
130
+ self.events.emit(event)
131
+
132
+ async def _aemit(self, event: CacheEvent) -> None:
133
+ """Dispatch an event from an asynchronous operation (sync + async callbacks)."""
134
+ self.events.emit(event)
135
+ await self.events.aemit(event)
136
+
118
137
  def set(
119
138
  self,
120
139
  key: str,
@@ -128,7 +147,7 @@ class CacheManager:
128
147
 
129
148
  self.backend.set(key, value, ttl, dependencies)
130
149
 
131
- self.events.emit(
150
+ self._emit(
132
151
  CacheEvent(
133
152
  event_type=CacheEventType.SET,
134
153
  key=key,
@@ -160,7 +179,7 @@ class CacheManager:
160
179
  else:
161
180
  raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
162
181
 
163
- self.events.emit(
182
+ await self._aemit(
164
183
  CacheEvent(
165
184
  event_type=CacheEventType.SET,
166
185
  key=key,
@@ -179,12 +198,10 @@ class CacheManager:
179
198
  value = self.backend.get(key)
180
199
 
181
200
  if value is None:
182
- self.events.emit(
183
- CacheEvent(event_type=CacheEventType.MISS, key=key, timestamp=time.time())
184
- )
201
+ self._emit(CacheEvent(event_type=CacheEventType.MISS, key=key, timestamp=time.time()))
185
202
  return None
186
203
 
187
- self.events.emit(
204
+ self._emit(
188
205
  CacheEvent(
189
206
  event_type=CacheEventType.HIT,
190
207
  key=key,
@@ -210,12 +227,12 @@ class CacheManager:
210
227
  raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
211
228
 
212
229
  if value is None:
213
- self.events.emit(
230
+ await self._aemit(
214
231
  CacheEvent(event_type=CacheEventType.MISS, key=key, timestamp=time.time())
215
232
  )
216
233
  return None
217
234
 
218
- self.events.emit(
235
+ await self._aemit(
219
236
  CacheEvent(
220
237
  event_type=CacheEventType.HIT,
221
238
  key=key,
@@ -233,7 +250,7 @@ class CacheManager:
233
250
  count = self.backend.delete(*keys)
234
251
 
235
252
  for key in keys:
236
- self.events.emit(
253
+ self._emit(
237
254
  CacheEvent(
238
255
  event_type=CacheEventType.DELETE,
239
256
  key=key,
@@ -260,7 +277,7 @@ class CacheManager:
260
277
  raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
261
278
 
262
279
  for key in keys:
263
- self.events.emit(
280
+ await self._aemit(
264
281
  CacheEvent(
265
282
  event_type=CacheEventType.DELETE,
266
283
  key=key,
@@ -278,7 +295,7 @@ class CacheManager:
278
295
 
279
296
  count = self.backend.clear(pattern)
280
297
 
281
- self.events.emit(
298
+ self._emit(
282
299
  CacheEvent(
283
300
  event_type=CacheEventType.CLEAR,
284
301
  key=pattern,
@@ -304,7 +321,7 @@ class CacheManager:
304
321
  else:
305
322
  raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
306
323
 
307
- self.events.emit(
324
+ await self._aemit(
308
325
  CacheEvent(
309
326
  event_type=CacheEventType.CLEAR,
310
327
  key=pattern,
@@ -325,7 +342,7 @@ class CacheManager:
325
342
  count = self.backend.invalidate_dependency(dependency)
326
343
 
327
344
  # Emit invalidate event
328
- self.events.emit(
345
+ self._emit(
329
346
  CacheEvent(
330
347
  event_type=CacheEventType.INVALIDATE,
331
348
  key=dependency,
@@ -352,7 +369,7 @@ class CacheManager:
352
369
  else:
353
370
  raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
354
371
 
355
- self.events.emit(
372
+ await self._aemit(
356
373
  CacheEvent(
357
374
  event_type=CacheEventType.INVALIDATE,
358
375
  key=dependency,
@@ -407,7 +424,7 @@ class CacheManager:
407
424
  else:
408
425
  raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
409
426
 
410
- def on_event(self, event_type: CacheEventType, callback: Callable[[CacheEvent], None]) -> None:
427
+ def on_event(self, event_type: CacheEventType, callback: CacheCallback) -> None:
411
428
  """Register a callback for a specific cache event type.
412
429
 
413
430
  Args:
@@ -416,7 +433,7 @@ class CacheManager:
416
433
  """
417
434
  self.events.on(event_type, callback)
418
435
 
419
- def on_all_events(self, callback: Callable[[CacheEvent], None]) -> None:
436
+ def on_all_events(self, callback: CacheCallback) -> None:
420
437
  """Register a callback for all cache events.
421
438
 
422
439
  Args:
@@ -424,9 +441,7 @@ class CacheManager:
424
441
  """
425
442
  self.events.on_all(callback)
426
443
 
427
- def remove_event_callback(
428
- self, event_type: CacheEventType, callback: Callable[[CacheEvent], None]
429
- ) -> bool:
444
+ def remove_event_callback(self, event_type: CacheEventType, callback: CacheCallback) -> bool:
430
445
  """Remove a callback for a specific event type.
431
446
 
432
447
  Args:
@@ -438,7 +453,7 @@ class CacheManager:
438
453
  """
439
454
  return self.events.off(event_type, callback)
440
455
 
441
- def remove_all_events_callback(self, callback: Callable[[CacheEvent], None]) -> bool:
456
+ def remove_all_events_callback(self, callback: CacheCallback) -> bool:
442
457
  """Remove a callback from all events.
443
458
 
444
459
  Args:
@@ -1,11 +1,15 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  from abc import ABC, abstractmethod
3
- from typing import TYPE_CHECKING, Any, Protocol, Self, runtime_checkable
5
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
4
6
 
5
7
  from .config import ConfigBase
6
8
  from .utils import DynamicImporter
7
9
 
8
10
  if TYPE_CHECKING:
11
+ from typing import Self
12
+
9
13
  import orjson
10
14
 
11
15
  try:
@@ -0,0 +1,111 @@
1
+ """Tests for async event-callback dispatch on the cache manager."""
2
+
3
+ import pytest
4
+
5
+ from simple_dep_cache.config import ConfigBase
6
+ from simple_dep_cache.events import CacheEvent, CacheEventType
7
+ from simple_dep_cache.fakes import FakeAsyncCacheBackend, FakeCacheBackend
8
+ from simple_dep_cache.manager import CacheManager
9
+
10
+
11
+ def _sync_manager():
12
+ config = ConfigBase(prefix="t")
13
+ return CacheManager(config, name="t", backend=FakeCacheBackend(config))
14
+
15
+
16
+ def _async_manager():
17
+ config = ConfigBase(prefix="t")
18
+ return CacheManager(config, name="t", async_backend=FakeAsyncCacheBackend(config))
19
+
20
+
21
+ class TestSyncDispatch:
22
+ def test_sync_callback_runs_on_sync_op(self):
23
+ seen = []
24
+ m = _sync_manager()
25
+ m.on_event(CacheEventType.INVALIDATE, lambda e: seen.append(e.key))
26
+ m.invalidate_dependency("dep:1")
27
+ assert seen == ["dep:1"]
28
+
29
+ def test_async_callback_on_sync_op_raises(self):
30
+ m = _sync_manager()
31
+
32
+ async def acb(event): # noqa: RUF029
33
+ pass
34
+
35
+ m.on_event(CacheEventType.INVALIDATE, acb)
36
+ with pytest.raises(RuntimeError, match="async callback"):
37
+ m.invalidate_dependency("dep:1")
38
+
39
+ def test_emit_skips_async_callbacks(self):
40
+ # EventEmitter.emit itself must never try to call an async callback.
41
+ sync_seen, async_seen = [], []
42
+ m = _sync_manager()
43
+
44
+ async def acb(event):
45
+ async_seen.append(event.key)
46
+
47
+ m.on_event(CacheEventType.SET, lambda e: sync_seen.append(e.key))
48
+ m.on_event(CacheEventType.SET, acb)
49
+ # direct emit -> only sync callback runs, no coroutine leaked
50
+ m.events.emit(CacheEvent(event_type=CacheEventType.SET, key="k", timestamp=0.0))
51
+ assert sync_seen == ["k"]
52
+ assert async_seen == []
53
+
54
+
55
+ class TestAsyncDispatch:
56
+ @pytest.mark.asyncio
57
+ async def test_async_callback_runs_on_async_op(self):
58
+ seen = []
59
+ m = _async_manager()
60
+
61
+ async def acb(event):
62
+ seen.append(event.key)
63
+
64
+ m.on_event(CacheEventType.INVALIDATE, acb)
65
+ await m.ainvalidate_dependency("dep:1")
66
+ assert seen == ["dep:1"]
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_sync_callback_also_runs_on_async_op(self):
70
+ seen = []
71
+ m = _async_manager()
72
+ m.on_event(CacheEventType.INVALIDATE, lambda e: seen.append(e.key))
73
+ await m.ainvalidate_dependency("dep:1")
74
+ assert seen == ["dep:1"]
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_both_sync_and_async_run_on_async_op(self):
78
+ sync_seen, async_seen = [], []
79
+ m = _async_manager()
80
+
81
+ async def acb(event):
82
+ async_seen.append(event.key)
83
+
84
+ m.on_event(CacheEventType.INVALIDATE, lambda e: sync_seen.append(e.key))
85
+ m.on_event(CacheEventType.INVALIDATE, acb)
86
+ await m.ainvalidate_dependency("dep:1")
87
+ assert sync_seen == ["dep:1"]
88
+ assert async_seen == ["dep:1"]
89
+
90
+
91
+ class TestHasAsyncCallbacks:
92
+ def test_detection(self):
93
+ m = _sync_manager()
94
+ assert m.events.has_async_callbacks(CacheEventType.SET) is False
95
+ m.on_event(CacheEventType.SET, lambda e: None)
96
+ assert m.events.has_async_callbacks(CacheEventType.SET) is False
97
+
98
+ async def acb(event):
99
+ pass
100
+
101
+ m.on_event(CacheEventType.SET, acb)
102
+ assert m.events.has_async_callbacks(CacheEventType.SET) is True
103
+
104
+ def test_global_async_callback_detected(self):
105
+ m = _sync_manager()
106
+
107
+ async def acb(event):
108
+ pass
109
+
110
+ m.on_all_events(acb)
111
+ assert m.events.has_async_callbacks(CacheEventType.HIT) is True
@@ -0,0 +1,207 @@
1
+ """Tests for the silent_backend_errors parameter in cache_with_deps decorator."""
2
+
3
+ from unittest.mock import Mock
4
+
5
+ import pytest
6
+
7
+ from simple_dep_cache.decorators import cache_with_deps
8
+ from simple_dep_cache.fakes import FakeAsyncCacheBackend, FakeCacheBackend, FakeConfig
9
+ from simple_dep_cache.manager import get_or_create_cache_manager
10
+
11
+
12
+ class TestSilentBackendErrors:
13
+ """Test cases for silent_backend_errors parameter."""
14
+
15
+ def test_silent_backend_errors_on_get(self):
16
+ """Test that backend errors during get are silently logged."""
17
+ # Create a fake backend that will raise an error on get
18
+ config = FakeConfig(prefix="test_get_error")
19
+ backend = FakeCacheBackend(config)
20
+ cache_manager = get_or_create_cache_manager(
21
+ name="test_get_error", config=config, backend=backend
22
+ )
23
+ assert cache_manager is not None
24
+
25
+ # Mock the backend's get method to raise an error
26
+ backend.get = Mock(side_effect=ConnectionError("Redis connection failed"))
27
+
28
+ call_count = 0
29
+
30
+ @cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
31
+ def my_function(x):
32
+ nonlocal call_count
33
+ call_count += 1
34
+ return x * 2
35
+
36
+ # Should execute function normally despite backend error
37
+ result = my_function(5)
38
+ assert result == 10
39
+ assert call_count == 1
40
+
41
+ def test_silent_backend_errors_on_set(self):
42
+ """Test that backend errors during set are silently logged."""
43
+ # Create a fake backend that will raise an error on set
44
+ config = FakeConfig(prefix="test_set_error")
45
+ backend = FakeCacheBackend(config)
46
+ cache_manager = get_or_create_cache_manager(
47
+ name="test_set_error", config=config, backend=backend
48
+ )
49
+ assert cache_manager is not None
50
+
51
+ # Mock the backend's set method to raise an error
52
+ backend.set = Mock(side_effect=ConnectionError("Redis connection failed"))
53
+
54
+ call_count = 0
55
+
56
+ @cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
57
+ def my_function(x):
58
+ nonlocal call_count
59
+ call_count += 1
60
+ return x * 2
61
+
62
+ # Should execute function normally despite backend error
63
+ result = my_function(5)
64
+ assert result == 10
65
+ assert call_count == 1
66
+
67
+ def test_silent_backend_errors_disabled_by_default(self):
68
+ """Test that backend errors are raised by default (silent_backend_errors=False)."""
69
+ # Create a fake backend that will raise an error on get
70
+ config = FakeConfig(prefix="test_error_default")
71
+ backend = FakeCacheBackend(config)
72
+ cache_manager = get_or_create_cache_manager(
73
+ name="test_error_default", config=config, backend=backend
74
+ )
75
+ assert cache_manager is not None
76
+
77
+ # Mock the backend's get method to raise an error
78
+ backend.get = Mock(side_effect=ConnectionError("Redis connection failed"))
79
+
80
+ @cache_with_deps(name=cache_manager.name)
81
+ def my_function(x):
82
+ return x * 2
83
+
84
+ # Should raise the backend error
85
+ with pytest.raises(ConnectionError, match="Redis connection failed"):
86
+ my_function(5)
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_silent_backend_errors_with_async(self):
90
+ """Test that silent_backend_errors works with async functions."""
91
+ # Create a fake async backend that will raise an error on get
92
+ config = FakeConfig(prefix="test_async_error")
93
+ async_backend = FakeAsyncCacheBackend(config)
94
+ cache_manager = get_or_create_cache_manager(
95
+ name="test_async_error",
96
+ config=config,
97
+ async_backend=async_backend,
98
+ create_async_backend=True,
99
+ )
100
+ assert cache_manager is not None
101
+
102
+ # Mock the backend's get method to raise an error
103
+ async def mock_get(key):
104
+ raise ConnectionError("Redis connection failed")
105
+
106
+ async_backend.get = mock_get
107
+
108
+ call_count = 0
109
+
110
+ @cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
111
+ async def my_async_function(x):
112
+ nonlocal call_count
113
+ call_count += 1
114
+ return x * 2
115
+
116
+ # Should execute function normally despite backend error
117
+ result = await my_async_function(5)
118
+ assert result == 10
119
+ assert call_count == 1
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_silent_backend_errors_async_disabled_by_default(self):
123
+ """Test that backend errors are raised by default in async functions."""
124
+ # Create a fake async backend that will raise an error on get
125
+ config = FakeConfig(prefix="test_async_error_default")
126
+ async_backend = FakeAsyncCacheBackend(config)
127
+ cache_manager = get_or_create_cache_manager(
128
+ name="test_async_error_default",
129
+ config=config,
130
+ async_backend=async_backend,
131
+ create_async_backend=True,
132
+ )
133
+ assert cache_manager is not None
134
+
135
+ # Mock the backend's get method to raise an error
136
+ async def mock_get(key):
137
+ raise ConnectionError("Redis connection failed")
138
+
139
+ async_backend.get = mock_get
140
+
141
+ @cache_with_deps(name=cache_manager.name)
142
+ async def my_async_function(x):
143
+ return x * 2
144
+
145
+ # Should raise the backend error
146
+ with pytest.raises(ConnectionError, match="Redis connection failed"):
147
+ await my_async_function(5)
148
+
149
+ def test_silent_backend_errors_multiple_calls(self):
150
+ """Test that function executes correctly with persistent backend errors."""
151
+ config = FakeConfig(prefix="test_multiple_calls")
152
+ backend = FakeCacheBackend(config)
153
+ cache_manager = get_or_create_cache_manager(
154
+ name="test_multiple_calls", config=config, backend=backend
155
+ )
156
+ assert cache_manager is not None
157
+
158
+ # Mock the backend to always fail
159
+ backend.get = Mock(side_effect=ConnectionError("Redis connection failed"))
160
+ backend.set = Mock(side_effect=ConnectionError("Redis connection failed"))
161
+
162
+ call_count = 0
163
+
164
+ @cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
165
+ def my_function(x):
166
+ nonlocal call_count
167
+ call_count += 1
168
+ return x * 2
169
+
170
+ # Call multiple times - should work each time
171
+ result1 = my_function(5)
172
+ result2 = my_function(10)
173
+ result3 = my_function(5) # Same arg as first call
174
+
175
+ assert result1 == 10
176
+ assert result2 == 20
177
+ assert result3 == 10
178
+ assert call_count == 3 # Function called every time (no caching due to backend errors)
179
+
180
+ def test_silent_backend_errors_partial_failure(self):
181
+ """Test behavior when only get fails but set works."""
182
+ config = FakeConfig(prefix="test_partial_failure")
183
+ backend = FakeCacheBackend(config)
184
+ cache_manager = get_or_create_cache_manager(
185
+ name="test_partial_failure", config=config, backend=backend
186
+ )
187
+ assert cache_manager is not None
188
+
189
+ # Mock the backend's get to fail, but set works normally
190
+ backend.get = Mock(side_effect=ConnectionError("Redis connection failed"))
191
+ call_count = 0
192
+
193
+ @cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
194
+ def my_function(x):
195
+ nonlocal call_count
196
+ call_count += 1
197
+ return x * 2
198
+
199
+ # First call: get fails, function executes, set succeeds
200
+ result1 = my_function(5)
201
+ assert result1 == 10
202
+ assert call_count == 1
203
+
204
+ # Second call: get still fails, function executes again
205
+ result2 = my_function(5)
206
+ assert result2 == 10
207
+ assert call_count == 2 # Function called again due to get failure
File without changes