simple-dep-cache 0.1.1__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.
@@ -0,0 +1,19 @@
1
+ from .context import add_dependency, current_cache_key
2
+ from .decorators import async_cache_with_deps, cache_with_deps
3
+ from .events import CacheEvent, CacheEventType, StatsCollector, create_logger_callback
4
+ from .manager import AsyncCacheManager, CacheManager
5
+ from .types import CacheValue
6
+
7
+ __all__ = [
8
+ "CacheManager",
9
+ "AsyncCacheManager",
10
+ "cache_with_deps",
11
+ "async_cache_with_deps",
12
+ "add_dependency",
13
+ "current_cache_key",
14
+ "CacheValue",
15
+ "CacheEvent",
16
+ "CacheEventType",
17
+ "StatsCollector",
18
+ "create_logger_callback",
19
+ ]
@@ -0,0 +1,190 @@
1
+ import os
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ import redis
6
+ import redis.asyncio
7
+
8
+
9
+ def _str_to_bool(value: str | bool) -> bool:
10
+ """Convert string environment variable to boolean."""
11
+ if isinstance(value, bool):
12
+ return value
13
+ if isinstance(value, str):
14
+ if value.lower() in ("true", "1", "yes", "on"):
15
+ return True
16
+ return False
17
+
18
+
19
+ def _str_to_int(value: str, default: int) -> int:
20
+ """Convert string environment variable to integer."""
21
+ try:
22
+ return int(value)
23
+ except (ValueError, TypeError):
24
+ return default
25
+
26
+
27
+ class Config:
28
+ """Configuration settings for simple_dep_cache."""
29
+
30
+ @property
31
+ def cache_enabled(self) -> bool:
32
+ """Whether caching is enabled. Can be disabled with DEP_CACHE_ENABLED=false."""
33
+ return _str_to_bool(os.getenv("DEP_CACHE_ENABLED", "true"))
34
+
35
+ @property
36
+ def callback_error_silent(self) -> bool:
37
+ """Whether to silently ignore callback errors or print traceback.
38
+
39
+ Set DEP_CACHE_CALLBACK_SILENT=false to print tracebacks.
40
+ """
41
+ return _str_to_bool(os.getenv("DEP_CACHE_CALLBACK_SILENT", "true"))
42
+
43
+ @property
44
+ def redis_url(self) -> str | None:
45
+ """Redis connection URL. Takes precedence over host/port/db settings.
46
+
47
+ Example: redis://localhost:6379/0 or redis://user:pass@host:port/db
48
+ Environment variable: REDIS_URL
49
+ """
50
+ return os.getenv("REDIS_URL")
51
+
52
+ @property
53
+ def redis_host(self) -> str:
54
+ """Redis host. Default: localhost
55
+ Environment variable: REDIS_HOST
56
+ """
57
+ return os.getenv("REDIS_HOST", "localhost")
58
+
59
+ @property
60
+ def redis_port(self) -> int:
61
+ """Redis port. Default: 6379
62
+ Environment variable: REDIS_PORT
63
+ """
64
+ return _str_to_int(os.getenv("REDIS_PORT", "6379"), 6379)
65
+
66
+ @property
67
+ def redis_db(self) -> int:
68
+ """Redis database number. Default: 0
69
+ Environment variable: REDIS_DB
70
+ """
71
+ return _str_to_int(os.getenv("REDIS_DB", "0"), 0)
72
+
73
+ @property
74
+ def redis_password(self) -> str | None:
75
+ """Redis password. Default: None
76
+ Environment variable: REDIS_PASSWORD
77
+ """
78
+ return os.getenv("REDIS_PASSWORD")
79
+
80
+ @property
81
+ def redis_username(self) -> str | None:
82
+ """Redis username (for Redis 6+). Default: None
83
+ Environment variable: REDIS_USERNAME
84
+ """
85
+ return os.getenv("REDIS_USERNAME")
86
+
87
+ @property
88
+ def redis_ssl(self) -> bool:
89
+ """Whether to use SSL/TLS for Redis connection. Default: False
90
+ Environment variable: REDIS_SSL
91
+ """
92
+ return _str_to_bool(os.getenv("REDIS_SSL", "false"))
93
+
94
+ @property
95
+ def redis_socket_timeout(self) -> float | None:
96
+ """Redis socket timeout in seconds. Default: None
97
+ Environment variable: REDIS_SOCKET_TIMEOUT
98
+ """
99
+ timeout_str = os.getenv("REDIS_SOCKET_TIMEOUT")
100
+ if timeout_str:
101
+ try:
102
+ return float(timeout_str)
103
+ except (ValueError, TypeError):
104
+ pass
105
+ return None
106
+
107
+ @property
108
+ def redis_connection_pool_max_connections(self) -> int:
109
+ """Maximum connections in Redis connection pool. Default: 50
110
+ Environment variable: REDIS_MAX_CONNECTIONS
111
+ """
112
+ return _str_to_int(os.getenv("REDIS_MAX_CONNECTIONS", "50"), 50)
113
+
114
+
115
+ def create_redis_client_from_config(
116
+ config_instance: Config | None = None,
117
+ ) -> "redis.Redis":
118
+ """Create a Redis client from configuration settings."""
119
+ import redis
120
+
121
+ cfg = config_instance or config
122
+
123
+ if cfg.redis_url:
124
+ return redis.Redis.from_url(
125
+ cfg.redis_url,
126
+ decode_responses=True,
127
+ socket_timeout=cfg.redis_socket_timeout,
128
+ max_connections=cfg.redis_connection_pool_max_connections,
129
+ )
130
+
131
+ connection_kwargs = {
132
+ "host": cfg.redis_host,
133
+ "port": cfg.redis_port,
134
+ "db": cfg.redis_db,
135
+ "decode_responses": True,
136
+ "ssl": cfg.redis_ssl,
137
+ "max_connections": cfg.redis_connection_pool_max_connections,
138
+ }
139
+
140
+ if cfg.redis_password:
141
+ connection_kwargs["password"] = cfg.redis_password
142
+
143
+ if cfg.redis_username:
144
+ connection_kwargs["username"] = cfg.redis_username
145
+
146
+ if cfg.redis_socket_timeout:
147
+ connection_kwargs["socket_timeout"] = cfg.redis_socket_timeout
148
+
149
+ return redis.Redis(**connection_kwargs)
150
+
151
+
152
+ def create_async_redis_client_from_config(
153
+ config_instance: Config | None = None,
154
+ ) -> "redis.asyncio.Redis":
155
+ """Create an async Redis client from configuration settings."""
156
+ import redis.asyncio as async_redis
157
+
158
+ cfg = config_instance or config
159
+
160
+ if cfg.redis_url:
161
+ return async_redis.Redis.from_url(
162
+ cfg.redis_url,
163
+ decode_responses=True,
164
+ socket_timeout=cfg.redis_socket_timeout,
165
+ max_connections=cfg.redis_connection_pool_max_connections,
166
+ )
167
+
168
+ connection_kwargs = {
169
+ "host": cfg.redis_host,
170
+ "port": cfg.redis_port,
171
+ "db": cfg.redis_db,
172
+ "decode_responses": True,
173
+ "ssl": cfg.redis_ssl,
174
+ "max_connections": cfg.redis_connection_pool_max_connections,
175
+ }
176
+
177
+ if cfg.redis_password:
178
+ connection_kwargs["password"] = cfg.redis_password
179
+
180
+ if cfg.redis_username:
181
+ connection_kwargs["username"] = cfg.redis_username
182
+
183
+ if cfg.redis_socket_timeout:
184
+ connection_kwargs["socket_timeout"] = cfg.redis_socket_timeout
185
+
186
+ return async_redis.Redis(**connection_kwargs)
187
+
188
+
189
+ # Global config instance
190
+ config = Config()
@@ -0,0 +1,38 @@
1
+ from contextvars import ContextVar
2
+
3
+ _current_cache_key: ContextVar[str | None] = ContextVar("current_cache_key", default=None)
4
+ _current_dependencies: ContextVar[None | set[str]] = ContextVar(
5
+ "current_dependencies", default=None
6
+ )
7
+
8
+
9
+ def set_current_cache_key(key: str) -> None:
10
+ """Set the current cache key in context."""
11
+ _current_cache_key.set(key)
12
+
13
+
14
+ def current_cache_key() -> str | None:
15
+ """Get the current cache key from context."""
16
+ return _current_cache_key.get()
17
+
18
+
19
+ def add_dependency(dependency: str) -> None:
20
+ """Add a dependency to the current cache context."""
21
+ deps = (_current_dependencies.get() or set()).copy()
22
+ deps.add(dependency)
23
+ _current_dependencies.set(deps)
24
+
25
+
26
+ def get_current_dependencies() -> set[str]:
27
+ """Get all dependencies for the current cache context."""
28
+ return (_current_dependencies.get() or set()).copy()
29
+
30
+
31
+ def clear_current_dependencies() -> None:
32
+ """Clear all dependencies in the current context."""
33
+ _current_dependencies.set(set())
34
+
35
+
36
+ def set_current_dependencies(dependencies: set[str]) -> None:
37
+ """Set the current dependencies."""
38
+ _current_dependencies.set(dependencies)
@@ -0,0 +1,193 @@
1
+ import asyncio
2
+ import hashlib
3
+ from collections.abc import Callable
4
+ from functools import wraps
5
+
6
+ from .config import config
7
+ from .context import (
8
+ clear_current_dependencies,
9
+ get_current_dependencies,
10
+ set_current_cache_key,
11
+ set_current_dependencies,
12
+ )
13
+ from .manager import AsyncCacheManager, CacheManager
14
+
15
+
16
+ def _generate_cache_key(func: Callable, args: tuple, kwargs: dict) -> str:
17
+ """Generate a cache key based on function name and arguments."""
18
+ func_name = f"{func.__module__}.{func.__qualname__}"
19
+
20
+ # Create a stable string representation of arguments
21
+ arg_parts = []
22
+
23
+ # Add positional args
24
+ for arg in args:
25
+ arg_parts.append(str(arg))
26
+
27
+ # Add keyword args (sorted for consistency)
28
+ for key in sorted(kwargs.keys()):
29
+ arg_parts.append(f"{key}={kwargs[key]}")
30
+
31
+ args_str = ",".join(arg_parts)
32
+ full_key = f"{func_name}({args_str})"
33
+
34
+ # Hash for consistent length and avoid special characters
35
+ return hashlib.md5(full_key.encode()).hexdigest()
36
+
37
+
38
+ def cache_with_deps(
39
+ *,
40
+ cache_manager: CacheManager | None = None,
41
+ ttl: int | None = None,
42
+ key_prefix: str | None = None,
43
+ dependencies: set | None = None,
44
+ ) -> Callable:
45
+ """
46
+ Decorator for caching function results with dependency tracking.
47
+
48
+ Args:
49
+ cache_manager: The cache manager instance to use (optional)
50
+ ttl: Time to live in seconds (optional)
51
+ key_prefix: Custom prefix for cache keys (optional)
52
+ dependencies: Additional dependencies to track (optional)
53
+ """
54
+
55
+ def decorator(func: Callable) -> Callable:
56
+ @wraps(func)
57
+ def wrapper(*args, **kwargs):
58
+ # If caching is disabled, just execute the function
59
+ if not config.cache_enabled:
60
+ return func(*args, **kwargs)
61
+
62
+ # Use provided cache manager or create default one
63
+ active_cache_manager = cache_manager or CacheManager()
64
+
65
+ # Generate cache key
66
+ cache_key = _generate_cache_key(func, args, kwargs)
67
+ if key_prefix:
68
+ cache_key = f"{key_prefix}:{cache_key}"
69
+
70
+ # Check cache first
71
+ cached_result = active_cache_manager.get(cache_key)
72
+ if cached_result is not None:
73
+ return cached_result
74
+
75
+ # Set up context for dependency tracking
76
+ old_dependencies = get_current_dependencies()
77
+ clear_current_dependencies()
78
+ set_current_cache_key(cache_key)
79
+
80
+ try:
81
+ # Execute function
82
+ result = func(*args, **kwargs)
83
+
84
+ # Get dependencies collected during execution
85
+ collected_dependencies = get_current_dependencies()
86
+
87
+ # Combine collected dependencies with additional dependencies
88
+ all_dependencies = set()
89
+ if collected_dependencies:
90
+ all_dependencies.update(collected_dependencies)
91
+ if dependencies:
92
+ all_dependencies.update(dependencies)
93
+
94
+ # Cache the result with dependencies
95
+ active_cache_manager.set(
96
+ cache_key,
97
+ result,
98
+ ttl,
99
+ all_dependencies if all_dependencies else None,
100
+ )
101
+
102
+ return result
103
+
104
+ finally:
105
+ # Restore previous context
106
+ set_current_dependencies(old_dependencies)
107
+ set_current_cache_key(None)
108
+
109
+ return wrapper
110
+
111
+ return decorator
112
+
113
+
114
+ def async_cache_with_deps(
115
+ *,
116
+ cache_manager: AsyncCacheManager | None = None,
117
+ ttl: int | None = None,
118
+ key_prefix: str | None = None,
119
+ dependencies: set | None = None,
120
+ ) -> Callable:
121
+ """
122
+ Async decorator for caching function results with dependency tracking.
123
+
124
+ Args:
125
+ cache_manager: The async cache manager instance to use (optional)
126
+ ttl: Time to live in seconds (optional)
127
+ key_prefix: Custom prefix for cache keys (optional)
128
+ dependencies: Additional dependencies to track (optional)
129
+ """
130
+
131
+ def decorator(func: Callable) -> Callable:
132
+ @wraps(func)
133
+ async def wrapper(*args, **kwargs):
134
+ # If caching is disabled, just execute the function
135
+ if not config.cache_enabled:
136
+ if asyncio.iscoroutinefunction(func):
137
+ return await func(*args, **kwargs)
138
+ else:
139
+ return func(*args, **kwargs)
140
+
141
+ # Use provided cache manager or create default one
142
+ active_cache_manager = cache_manager or AsyncCacheManager()
143
+
144
+ # Generate cache key
145
+ cache_key = _generate_cache_key(func, args, kwargs)
146
+ if key_prefix:
147
+ cache_key = f"{key_prefix}:{cache_key}"
148
+
149
+ # Check cache first
150
+ cached_result = await active_cache_manager.get(cache_key)
151
+ if cached_result is not None:
152
+ return cached_result
153
+
154
+ # Set up context for dependency tracking
155
+ old_dependencies = get_current_dependencies()
156
+ clear_current_dependencies()
157
+ set_current_cache_key(cache_key)
158
+
159
+ try:
160
+ # Execute function
161
+ if asyncio.iscoroutinefunction(func):
162
+ result = await func(*args, **kwargs)
163
+ else:
164
+ result = func(*args, **kwargs)
165
+
166
+ # Get dependencies collected during execution
167
+ collected_dependencies = get_current_dependencies()
168
+
169
+ # Combine collected dependencies with additional dependencies
170
+ all_dependencies = set()
171
+ if collected_dependencies:
172
+ all_dependencies.update(collected_dependencies)
173
+ if dependencies:
174
+ all_dependencies.update(dependencies)
175
+
176
+ # Cache the result with dependencies
177
+ await active_cache_manager.set(
178
+ cache_key,
179
+ result,
180
+ ttl,
181
+ all_dependencies if all_dependencies else None,
182
+ )
183
+
184
+ return result
185
+
186
+ finally:
187
+ # Restore previous context
188
+ set_current_dependencies(old_dependencies)
189
+ set_current_cache_key(None)
190
+
191
+ return wrapper
192
+
193
+ return decorator
@@ -0,0 +1,166 @@
1
+ import logging
2
+ import time
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from .config import config
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class CacheEventType(Enum):
14
+ """Types of cache events."""
15
+
16
+ HIT = "hit"
17
+ MISS = "miss"
18
+ SET = "set"
19
+ DELETE = "delete"
20
+ INVALIDATE = "invalidate"
21
+ CLEAR = "clear"
22
+
23
+
24
+ @dataclass
25
+ class CacheEvent:
26
+ """Cache event data."""
27
+
28
+ event_type: CacheEventType
29
+ key: str
30
+ timestamp: float
31
+ value: Any = None
32
+ dependencies: set[str] | None = None
33
+ ttl: int | None = None
34
+ count: int | None = None # For bulk operations
35
+
36
+ def __post_init__(self):
37
+ if self.timestamp is None:
38
+ self.timestamp = time.time()
39
+
40
+
41
+ class EventEmitter:
42
+ """Simple event emitter for cache events."""
43
+
44
+ def __init__(self):
45
+ self._callbacks: dict[CacheEventType, list[Callable]] = {
46
+ event_type: [] for event_type in CacheEventType
47
+ }
48
+ self._global_callbacks: list[Callable] = []
49
+
50
+ def on(self, event_type: CacheEventType, callback: Callable[[CacheEvent], None]) -> None:
51
+ """Register a callback for a specific event type."""
52
+ self._callbacks[event_type].append(callback)
53
+
54
+ def on_all(self, callback: Callable[[CacheEvent], None]) -> None:
55
+ """Register a callback for all event types."""
56
+ self._global_callbacks.append(callback)
57
+
58
+ def off(self, event_type: CacheEventType, callback: Callable[[CacheEvent], None]) -> bool:
59
+ """Unregister a callback for a specific event type."""
60
+ if callback in self._callbacks[event_type]:
61
+ self._callbacks[event_type].remove(callback)
62
+ return True
63
+ return False
64
+
65
+ def off_all(self, callback: Callable[[CacheEvent], None]) -> bool:
66
+ """Unregister a callback from all events."""
67
+ if callback in self._global_callbacks:
68
+ self._global_callbacks.remove(callback)
69
+ return True
70
+ return False
71
+
72
+ def emit(self, event: CacheEvent) -> None:
73
+ """Emit an event to all registered callbacks."""
74
+ # Call specific event callbacks
75
+ for callback in self._callbacks[event.event_type]:
76
+ try:
77
+ callback(event)
78
+ except Exception as e:
79
+ if not config.callback_error_silent:
80
+ logger.exception("Error in cache event callback: %s", e)
81
+
82
+ # Call global callbacks
83
+ for callback in self._global_callbacks:
84
+ try:
85
+ callback(event)
86
+ except Exception as e:
87
+ if not config.callback_error_silent:
88
+ logger.exception("Error in cache event callback: %s", e)
89
+
90
+ def clear_all(self) -> None:
91
+ """Clear all callbacks."""
92
+ for event_type in CacheEventType:
93
+ self._callbacks[event_type].clear()
94
+ self._global_callbacks.clear()
95
+
96
+
97
+ # Pre-built callback functions for common use cases
98
+ class StatsCollector:
99
+ """Collect cache statistics."""
100
+
101
+ def __init__(self):
102
+ self.stats = {
103
+ "hits": 0,
104
+ "misses": 0,
105
+ "sets": 0,
106
+ "deletes": 0,
107
+ "invalidations": 0,
108
+ "clears": 0,
109
+ }
110
+ self.start_time = time.time()
111
+
112
+ def __call__(self, event: CacheEvent) -> None:
113
+ """Callback to collect stats."""
114
+ if event.event_type == CacheEventType.HIT:
115
+ self.stats["hits"] += 1
116
+ elif event.event_type == CacheEventType.MISS:
117
+ self.stats["misses"] += 1
118
+ elif event.event_type == CacheEventType.SET:
119
+ self.stats["sets"] += 1
120
+ elif event.event_type == CacheEventType.DELETE:
121
+ self.stats["deletes"] += event.count or 1
122
+ elif event.event_type == CacheEventType.INVALIDATE:
123
+ self.stats["invalidations"] += event.count or 1
124
+ elif event.event_type == CacheEventType.CLEAR:
125
+ self.stats["clears"] += event.count or 1
126
+
127
+ def get_hit_ratio(self) -> float:
128
+ """Get cache hit ratio."""
129
+ total = self.stats["hits"] + self.stats["misses"]
130
+ return self.stats["hits"] / total if total > 0 else 0.0
131
+
132
+ def get_stats(self) -> dict:
133
+ """Get all stats with additional computed metrics."""
134
+ runtime = time.time() - self.start_time
135
+ return {
136
+ **self.stats,
137
+ "hit_ratio": self.get_hit_ratio(),
138
+ "total_operations": sum(self.stats.values()),
139
+ "runtime_seconds": runtime,
140
+ "ops_per_second": sum(self.stats.values()) / runtime if runtime > 0 else 0,
141
+ }
142
+
143
+ def reset(self) -> None:
144
+ """Reset all statistics."""
145
+ for key in self.stats:
146
+ self.stats[key] = 0
147
+ self.start_time = time.time()
148
+
149
+
150
+ def create_logger_callback(name: str = "cache") -> Callable[[CacheEvent], None]:
151
+ """Create a callback that logs cache events."""
152
+
153
+ def logger_callback(event: CacheEvent) -> None:
154
+ if event.event_type in (CacheEventType.HIT, CacheEventType.MISS):
155
+ print(f"[{name}] {event.event_type.value.upper()}: {event.key}")
156
+ elif event.event_type == CacheEventType.SET:
157
+ deps_str = f" deps={list(event.dependencies)}" if event.dependencies else ""
158
+ ttl_str = f" ttl={event.ttl}s" if event.ttl else ""
159
+ print(f"[{name}] SET: {event.key}{deps_str}{ttl_str}")
160
+ elif event.event_type == CacheEventType.INVALIDATE:
161
+ print(f"[{name}] INVALIDATE: {event.key} (cleared {event.count} entries)")
162
+ elif event.event_type in (CacheEventType.DELETE, CacheEventType.CLEAR):
163
+ count_str = f" ({event.count} entries)" if event.count else ""
164
+ print(f"[{name}] {event.event_type.value.upper()}: {event.key}{count_str}")
165
+
166
+ return logger_callback
@@ -0,0 +1,341 @@
1
+ import logging
2
+ import time
3
+
4
+ import redis
5
+ import redis.asyncio as async_redis
6
+
7
+ from .config import (
8
+ create_async_redis_client_from_config,
9
+ create_redis_client_from_config,
10
+ )
11
+ from .events import CacheEvent, CacheEventType, EventEmitter
12
+ from .types import CacheValue, deserialize_value, serialize_value
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class CacheManager:
18
+ """Synchronous Redis-based cache manager with dependency tracking."""
19
+
20
+ def __init__(self, redis_client: redis.Redis | None = None, prefix: str = "cache"):
21
+ if redis_client is None:
22
+ from .config import config
23
+
24
+ self._redis = None
25
+ if config.cache_enabled:
26
+ logger.info(
27
+ "Creating Redis client from environment configuration. "
28
+ "Provide a custom redis_client parameter to override."
29
+ )
30
+ self._redis = create_redis_client_from_config()
31
+ else:
32
+ self._redis = redis_client
33
+ self.prefix = prefix
34
+ self.events = EventEmitter()
35
+
36
+ @property
37
+ def redis(self) -> redis.Redis:
38
+ """Get the Redis client, raising an error if not configured."""
39
+ if self._redis is None:
40
+ raise RuntimeError("Cache is disabled or redis client is not configured.")
41
+ return self._redis
42
+
43
+ def _cache_key(self, key: str) -> str:
44
+ """Generate prefixed cache key."""
45
+ return f"{self.prefix}:{key}"
46
+
47
+ def _deps_key(self, dependency: str) -> str:
48
+ """Generate dependency tracking key."""
49
+ return f"{self.prefix}:deps:{dependency}"
50
+
51
+ def set(
52
+ self,
53
+ key: str,
54
+ value: CacheValue,
55
+ ttl: int | None = None,
56
+ dependencies: set[str] | None = None,
57
+ ) -> None:
58
+ """Set a cache value with optional TTL and dependencies."""
59
+ cache_key = self._cache_key(key)
60
+ serialized_value = serialize_value(value)
61
+
62
+ if ttl:
63
+ self.redis.setex(cache_key, ttl, serialized_value)
64
+ else:
65
+ self.redis.set(cache_key, serialized_value)
66
+
67
+ if dependencies:
68
+ for dep in dependencies:
69
+ dep_key = self._deps_key(dep)
70
+ self.redis.sadd(dep_key, cache_key)
71
+ if ttl:
72
+ current_ttl = self.redis.ttl(dep_key)
73
+ # Ensure dependency tracking key lives at least as long as cache entries
74
+ # current_ttl: -1 = no expiration, -2 = doesn't exist, >0 = remaining seconds
75
+ # Set/extend TTL if: key is persistent OR key has shorter TTL than ours
76
+ if current_ttl == -1 or (current_ttl != -2 and current_ttl < ttl):
77
+ self.redis.expire(dep_key, ttl)
78
+
79
+ # Emit set event
80
+ self.events.emit(
81
+ CacheEvent(
82
+ event_type=CacheEventType.SET,
83
+ key=key,
84
+ timestamp=time.time(),
85
+ value=value,
86
+ dependencies=dependencies,
87
+ ttl=ttl,
88
+ )
89
+ )
90
+
91
+ def get(self, key: str) -> CacheValue | None:
92
+ """Get a cache value."""
93
+ cache_key = self._cache_key(key)
94
+ value = self.redis.get(cache_key)
95
+
96
+ if value is None:
97
+ # Emit cache miss event
98
+ self.events.emit(
99
+ CacheEvent(event_type=CacheEventType.MISS, key=key, timestamp=time.time())
100
+ )
101
+ return None
102
+
103
+ # Emit cache hit event
104
+ deserialized_value = deserialize_value(value)
105
+ self.events.emit(
106
+ CacheEvent(
107
+ event_type=CacheEventType.HIT,
108
+ key=key,
109
+ timestamp=time.time(),
110
+ value=deserialized_value,
111
+ )
112
+ )
113
+ return deserialized_value
114
+
115
+ def delete(self, *keys: str) -> int:
116
+ """Delete cache entries."""
117
+ cache_keys = [self._cache_key(key) for key in keys]
118
+ count = self.redis.delete(*cache_keys) if cache_keys else 0
119
+
120
+ # Emit delete event for each key
121
+ for key in keys:
122
+ self.events.emit(
123
+ CacheEvent(
124
+ event_type=CacheEventType.DELETE,
125
+ key=key,
126
+ timestamp=time.time(),
127
+ count=1,
128
+ )
129
+ )
130
+
131
+ return count
132
+
133
+ def clear(self, pattern: str = "*") -> int:
134
+ """Clear cache entries matching pattern."""
135
+ pattern_key = self._cache_key(pattern)
136
+ keys = list(self.redis.scan_iter(match=pattern_key))
137
+ count = self.redis.delete(*keys) if keys else 0
138
+
139
+ # Emit clear event
140
+ self.events.emit(
141
+ CacheEvent(
142
+ event_type=CacheEventType.CLEAR,
143
+ key=pattern,
144
+ timestamp=time.time(),
145
+ count=count,
146
+ )
147
+ )
148
+
149
+ return count
150
+
151
+ def invalidate_dependency(self, dependency: str) -> int:
152
+ """Invalidate all cache entries that depend on the given dependency."""
153
+ dep_key = self._deps_key(dependency)
154
+ cache_keys = self.redis.smembers(dep_key)
155
+
156
+ if not cache_keys:
157
+ count = 0
158
+ else:
159
+ count = self.redis.delete(*cache_keys)
160
+ self.redis.delete(dep_key)
161
+
162
+ # Emit invalidate event
163
+ self.events.emit(
164
+ CacheEvent(
165
+ event_type=CacheEventType.INVALIDATE,
166
+ key=dependency,
167
+ timestamp=time.time(),
168
+ count=count,
169
+ )
170
+ )
171
+
172
+ return count
173
+
174
+ def exists(self, key: str) -> bool:
175
+ """Check if a cache key exists."""
176
+ return bool(self.redis.exists(self._cache_key(key)))
177
+
178
+ def ttl(self, key: str) -> int:
179
+ """Get TTL for a cache key."""
180
+ return self.redis.ttl(self._cache_key(key))
181
+
182
+
183
+ class AsyncCacheManager:
184
+ """Asynchronous Redis-based cache manager with dependency tracking."""
185
+
186
+ def __init__(self, redis_client: async_redis.Redis | None = None, prefix: str = "cache"):
187
+ if redis_client is None:
188
+ logger.info(
189
+ "Creating async Redis client from environment configuration. "
190
+ "Provide a custom redis_client parameter to override."
191
+ )
192
+ self.redis = create_async_redis_client_from_config()
193
+ else:
194
+ self.redis = redis_client
195
+ self.prefix = prefix
196
+ self.events = EventEmitter()
197
+
198
+ def _cache_key(self, key: str) -> str:
199
+ """Generate prefixed cache key."""
200
+ return f"{self.prefix}:{key}"
201
+
202
+ def _deps_key(self, dependency: str) -> str:
203
+ """Generate dependency tracking key."""
204
+ return f"{self.prefix}:deps:{dependency}"
205
+
206
+ async def set(
207
+ self,
208
+ key: str,
209
+ value: CacheValue,
210
+ ttl: int | None = None,
211
+ dependencies: set[str] | None = None,
212
+ ) -> None:
213
+ """Set a cache value with optional TTL and dependencies."""
214
+ cache_key = self._cache_key(key)
215
+ serialized_value = serialize_value(value)
216
+
217
+ if ttl:
218
+ await self.redis.setex(cache_key, ttl, serialized_value)
219
+ else:
220
+ await self.redis.set(cache_key, serialized_value)
221
+
222
+ if dependencies:
223
+ for dep in dependencies:
224
+ dep_key = self._deps_key(dep)
225
+ await self.redis.sadd(dep_key, cache_key)
226
+ if ttl:
227
+ current_ttl = await self.redis.ttl(dep_key)
228
+ # Ensure dependency tracking key lives at least as long as cache entries
229
+ # current_ttl: -1 = no expiration, -2 = doesn't exist, >0 = remaining seconds
230
+ # Set/extend TTL if: key is persistent OR key has shorter TTL than ours
231
+ if current_ttl == -1 or (current_ttl != -2 and current_ttl < ttl):
232
+ await self.redis.expire(dep_key, ttl)
233
+
234
+ # Emit set event
235
+ self.events.emit(
236
+ CacheEvent(
237
+ event_type=CacheEventType.SET,
238
+ key=key,
239
+ timestamp=time.time(),
240
+ value=value,
241
+ dependencies=dependencies,
242
+ ttl=ttl,
243
+ )
244
+ )
245
+
246
+ async def get(self, key: str) -> CacheValue | None:
247
+ """Get a cache value."""
248
+ cache_key = self._cache_key(key)
249
+ value = await self.redis.get(cache_key)
250
+
251
+ if value is None:
252
+ # Emit cache miss event
253
+ self.events.emit(
254
+ CacheEvent(event_type=CacheEventType.MISS, key=key, timestamp=time.time())
255
+ )
256
+ return None
257
+
258
+ # Emit cache hit event
259
+ deserialized_value = deserialize_value(value)
260
+ self.events.emit(
261
+ CacheEvent(
262
+ event_type=CacheEventType.HIT,
263
+ key=key,
264
+ timestamp=time.time(),
265
+ value=deserialized_value,
266
+ )
267
+ )
268
+ return deserialized_value
269
+
270
+ async def delete(self, *keys: str) -> int:
271
+ """Delete cache entries."""
272
+ cache_keys = [self._cache_key(key) for key in keys]
273
+ count = await self.redis.delete(*cache_keys) if cache_keys else 0
274
+
275
+ # Emit delete event for each key
276
+ for key in keys:
277
+ self.events.emit(
278
+ CacheEvent(
279
+ event_type=CacheEventType.DELETE,
280
+ key=key,
281
+ timestamp=time.time(),
282
+ count=1,
283
+ )
284
+ )
285
+
286
+ return count
287
+
288
+ async def clear(self, pattern: str = "*") -> int:
289
+ """Clear cache entries matching pattern."""
290
+ pattern_key = self._cache_key(pattern)
291
+ keys = []
292
+ async for key in self.redis.scan_iter(match=pattern_key):
293
+ keys.append(key)
294
+ count = await self.redis.delete(*keys) if keys else 0
295
+
296
+ # Emit clear event
297
+ self.events.emit(
298
+ CacheEvent(
299
+ event_type=CacheEventType.CLEAR,
300
+ key=pattern,
301
+ timestamp=time.time(),
302
+ count=count,
303
+ )
304
+ )
305
+
306
+ return count
307
+
308
+ async def invalidate_dependency(self, dependency: str) -> int:
309
+ """Invalidate all cache entries that depend on the given dependency."""
310
+ dep_key = self._deps_key(dependency)
311
+ cache_keys = await self.redis.smembers(dep_key)
312
+
313
+ if not cache_keys:
314
+ count = 0
315
+ else:
316
+ count = await self.redis.delete(*cache_keys)
317
+ await self.redis.delete(dep_key)
318
+
319
+ # Emit invalidate event
320
+ self.events.emit(
321
+ CacheEvent(
322
+ event_type=CacheEventType.INVALIDATE,
323
+ key=dependency,
324
+ timestamp=time.time(),
325
+ count=count,
326
+ )
327
+ )
328
+
329
+ return count
330
+
331
+ async def exists(self, key: str) -> bool:
332
+ """Check if a cache key exists."""
333
+ return bool(await self.redis.exists(self._cache_key(key)))
334
+
335
+ async def ttl(self, key: str) -> int:
336
+ """Get TTL for a cache key."""
337
+ return await self.redis.ttl(self._cache_key(key))
338
+
339
+ async def close(self) -> None:
340
+ """Close the Redis connection."""
341
+ await self.redis.aclose()
@@ -0,0 +1,34 @@
1
+ import json
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ import orjson
6
+
7
+ try:
8
+ import orjson
9
+
10
+ HAS_ORJSON = True
11
+ except ImportError:
12
+ HAS_ORJSON = False
13
+
14
+ CacheValue = str | int | float | bool | dict | list | None
15
+
16
+
17
+ def serialize_value(value: CacheValue) -> str:
18
+ """Serialize a cache value to string for Redis storage."""
19
+ if isinstance(value, str):
20
+ return value
21
+ if HAS_ORJSON:
22
+ return orjson.dumps(value).decode("utf-8")
23
+ return json.dumps(value)
24
+
25
+
26
+ def deserialize_value(value: str) -> CacheValue:
27
+ """Deserialize a string value from Redis back to Python object."""
28
+ JSONDecodeError = orjson.JSONDecodeError if HAS_ORJSON else json.JSONDecodeError
29
+ try:
30
+ if HAS_ORJSON:
31
+ return orjson.loads(value)
32
+ return json.loads(value)
33
+ except JSONDecodeError:
34
+ return value
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple-dep-cache
3
+ Version: 0.1.1
4
+ Summary: Redis-based caching library with intelligent dependency tracking for Python applications
5
+ License: MIT
6
+ Keywords: cache,cache invalidation,dependency,redis
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: redis
16
+ Provides-Extra: fast-json
17
+ Requires-Dist: orjson; extra == 'fast-json'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # simple-dep-cache
21
+
22
+ A Redis-based caching library with dependency tracking for Python applications.
23
+
24
+ ## Overview
25
+
26
+ Cache function results and automatically invalidate related caches when dependencies change. Uses Redis for distributed caching and supports both sync/async functions.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install simple-dep-cache
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ### Basic Usage
37
+
38
+ ```python
39
+ from simple_dep_cache import cache_with_deps, add_dependency, CacheManager
40
+
41
+ # Initialize cache manager (optional - will be created automatically if not provided)
42
+ cache = CacheManager()
43
+
44
+ @cache_with_deps(cache_manager=cache, ttl=300)
45
+ def get_user_profile(user_id):
46
+ # This function's result depends on user data
47
+ add_dependency(f"user:{user_id}")
48
+
49
+ # Expensive operation (e.g., database query, API call)
50
+ return fetch_user_from_database(user_id)
51
+
52
+ @cache_with_deps(ttl=600) # No cache_manager - will create one automatically
53
+ def get_user_posts(user_id):
54
+ # This depends on both user and posts data
55
+ add_dependency(f"user:{user_id}")
56
+ add_dependency(f"posts:user:{user_id}")
57
+
58
+ return fetch_user_posts_from_database(user_id)
59
+
60
+ # Use the cached functions
61
+ profile = get_user_profile("123") # Cache miss - fetches from DB
62
+ profile = get_user_profile("123") # Cache hit - returns cached result
63
+
64
+ posts = get_user_posts("123") # Cache miss - fetches from DB
65
+ posts = get_user_posts("123") # Cache hit - returns cached result
66
+
67
+ # When user data changes, invalidate the dependency
68
+ cache.invalidate_dependency("user:123")
69
+ # Now both get_user_profile("123") and get_user_posts("123") are invalidated!
70
+
71
+ profile = get_user_profile("123") # Cache miss - will fetch fresh data
72
+ ```
73
+
74
+ ### Async Support
75
+
76
+ ```python
77
+ from simple_dep_cache import async_cache_with_deps, add_dependency, AsyncCacheManager
78
+
79
+ cache = AsyncCacheManager()
80
+
81
+ @async_cache_with_deps(cache_manager=cache, ttl=300)
82
+ async def get_user_profile_async(user_id):
83
+ add_dependency(f"user:{user_id}")
84
+ return await fetch_user_from_database_async(user_id)
85
+
86
+ # Usage
87
+ profile = await get_user_profile_async("123") # Cache miss
88
+ profile = await get_user_profile_async("123") # Cache hit
89
+
90
+ # Invalidate dependency
91
+ await cache.invalidate_dependency("user:123")
92
+ ```
93
+
94
+ ### Monitoring
95
+
96
+ ```python
97
+ from simple_dep_cache import StatsCollector, create_logger_callback
98
+
99
+ cache = CacheManager()
100
+ stats = StatsCollector()
101
+ cache.events.on_all(stats)
102
+ cache.events.on_all(create_logger_callback("my_cache"))
103
+
104
+ # Check statistics
105
+ print(stats.get_stats()) # hit_ratio, ops_per_second, etc.
106
+ ```
107
+
108
+ ## Configuration
109
+
110
+ ```bash
111
+ REDIS_URL=redis://localhost:6379/0 # Full Redis URL (preferred)
112
+ REDIS_HOST=localhost # Or individual settings
113
+ REDIS_PORT=6379
114
+ REDIS_PASSWORD=secret
115
+ DEP_CACHE_ENABLED=true # Disable caching entirely
116
+ ```
117
+
118
+ ## Manual Cache Operations
119
+
120
+ ```python
121
+ cache = CacheManager()
122
+
123
+ # Direct operations
124
+ cache.set("key", value, ttl=300, dependencies={"dep1"})
125
+ value = cache.get("key")
126
+ cache.delete("key")
127
+ cache.invalidate_dependency("dep1") # Invalidates all dependent caches
128
+ ```
129
+
130
+ ## API Reference
131
+
132
+ **Decorators:**
133
+
134
+ - `@cache_with_deps(cache_manager, ttl, key_prefix, dependencies)`
135
+ - `@async_cache_with_deps(cache_manager, ttl, key_prefix, dependencies)`
136
+
137
+ If `cache_manager` is not provided, one will be created automatically using configured environment variables or defaults.
138
+
139
+ **Context:**
140
+
141
+ - `add_dependency(dependency)` - Track dependency in current function
142
+ - `current_cache_key()` - Get current cache key
143
+
144
+ **Managers:**
145
+
146
+ - `CacheManager(redis_client, prefix)` - Sync Redis cache manager
147
+ - `AsyncCacheManager(redis_client, prefix)` - Async Redis cache manager
148
+
149
+ **Monitoring:**
150
+
151
+ - `StatsCollector()` - Cache statistics
152
+
153
+ ## Requirements
154
+
155
+ - Python 3.10+
156
+ - Redis server
157
+ - `redis` package
158
+
159
+ ## License
160
+
161
+ MIT
@@ -0,0 +1,10 @@
1
+ simple_dep_cache/__init__.py,sha256=IFHKaQii5mF9hnYGkNRhNrCNwvxVW5cUkFyzhdIOTL0,557
2
+ simple_dep_cache/config.py,sha256=ebl_Fwj07RrDPPPfgWym6w8xZ3wI9OPhtSzBT3B9AlU,5612
3
+ simple_dep_cache/context.py,sha256=F6eaO01rQNAARq1r5H_LrXhJ9AJe8CysH21vx_Dok-w,1162
4
+ simple_dep_cache/decorators.py,sha256=Hu87FrvPKrjn-o8xGCO8yUpTq-WXuwdR2mP6jjkdsf0,6406
5
+ simple_dep_cache/events.py,sha256=5iLaulgO7c-x2Nafgn41Y1uCPuRSQv4JE0oQKND8EdM,5812
6
+ simple_dep_cache/manager.py,sha256=j_pgZQsvSbIvasCppRGBxdQb0iD6mr76YHQcn3fj19k,11176
7
+ simple_dep_cache/types.py,sha256=Kxu0k8rfVkK7kxIvEQG7NI7SQSgAiFnxPEfrHj5nRtk,855
8
+ simple_dep_cache-0.1.1.dist-info/METADATA,sha256=DwOWPen-TITVTrudO-9p6NDz5BJKQIAFk-7DT_TuLxs,4530
9
+ simple_dep_cache-0.1.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ simple_dep_cache-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any