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.
- simple_dep_cache/__init__.py +19 -0
- simple_dep_cache/config.py +190 -0
- simple_dep_cache/context.py +38 -0
- simple_dep_cache/decorators.py +193 -0
- simple_dep_cache/events.py +166 -0
- simple_dep_cache/manager.py +341 -0
- simple_dep_cache/types.py +34 -0
- simple_dep_cache-0.1.1.dist-info/METADATA +161 -0
- simple_dep_cache-0.1.1.dist-info/RECORD +10 -0
- simple_dep_cache-0.1.1.dist-info/WHEEL +4 -0
|
@@ -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,,
|