cacheql 0.0.1a0__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.
- cacheql/__init__.py +135 -0
- cacheql/adapters/__init__.py +1 -0
- cacheql/adapters/ariadne/__init__.py +18 -0
- cacheql/adapters/ariadne/decorators.py +236 -0
- cacheql/adapters/ariadne/extension.py +375 -0
- cacheql/adapters/ariadne/graphql.py +55 -0
- cacheql/adapters/ariadne/handler.py +137 -0
- cacheql/adapters/strawberry/__init__.py +5 -0
- cacheql/adapters/strawberry/extension.py +244 -0
- cacheql/core/__init__.py +24 -0
- cacheql/core/entities/__init__.py +23 -0
- cacheql/core/entities/cache_config.py +47 -0
- cacheql/core/entities/cache_control.py +236 -0
- cacheql/core/entities/cache_entry.py +73 -0
- cacheql/core/entities/cache_key.py +69 -0
- cacheql/core/interfaces/__init__.py +13 -0
- cacheql/core/interfaces/cache_backend.py +76 -0
- cacheql/core/interfaces/invalidator.py +45 -0
- cacheql/core/interfaces/key_builder.py +32 -0
- cacheql/core/interfaces/serializer.py +39 -0
- cacheql/core/services/__init__.py +27 -0
- cacheql/core/services/cache_control_calculator.py +259 -0
- cacheql/core/services/cache_service.py +206 -0
- cacheql/core/services/directive_parser.py +245 -0
- cacheql/decorators.py +250 -0
- cacheql/hints.py +200 -0
- cacheql/infrastructure/__init__.py +11 -0
- cacheql/infrastructure/backends/__init__.py +5 -0
- cacheql/infrastructure/backends/memory.py +132 -0
- cacheql/infrastructure/key_builders/__init__.py +5 -0
- cacheql/infrastructure/key_builders/default.py +104 -0
- cacheql/infrastructure/serializers/__init__.py +5 -0
- cacheql/infrastructure/serializers/json.py +83 -0
- cacheql/utils/__init__.py +5 -0
- cacheql/utils/hashing.py +38 -0
- cacheql-0.0.1a0.dist-info/METADATA +432 -0
- cacheql-0.0.1a0.dist-info/RECORD +38 -0
- cacheql-0.0.1a0.dist-info/WHEEL +4 -0
cacheql/__init__.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""CacheQL - Server-side caching framework for GraphQL APIs.
|
|
2
|
+
|
|
3
|
+
A Python library for caching GraphQL responses with support for
|
|
4
|
+
Apollo Server-style @cacheControl directives, query-level and
|
|
5
|
+
field-level caching, multiple backends, and framework adapters
|
|
6
|
+
for Ariadne and Strawberry.
|
|
7
|
+
|
|
8
|
+
Example with @cacheControl directives:
|
|
9
|
+
from cacheql import (
|
|
10
|
+
CacheService,
|
|
11
|
+
CacheConfig,
|
|
12
|
+
InMemoryCacheBackend,
|
|
13
|
+
DefaultKeyBuilder,
|
|
14
|
+
JsonSerializer,
|
|
15
|
+
)
|
|
16
|
+
from cacheql.adapters.ariadne import CacheExtension
|
|
17
|
+
|
|
18
|
+
# Schema with @cacheControl directives
|
|
19
|
+
type_defs = '''
|
|
20
|
+
directive @cacheControl(
|
|
21
|
+
maxAge: Int
|
|
22
|
+
scope: CacheControlScope
|
|
23
|
+
inheritMaxAge: Boolean
|
|
24
|
+
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
|
|
25
|
+
|
|
26
|
+
enum CacheControlScope { PUBLIC PRIVATE }
|
|
27
|
+
|
|
28
|
+
type Query {
|
|
29
|
+
users: [User!]! @cacheControl(maxAge: 300)
|
|
30
|
+
me: User @cacheControl(maxAge: 60, scope: PRIVATE)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type User @cacheControl(maxAge: 600) {
|
|
34
|
+
id: ID!
|
|
35
|
+
name: String!
|
|
36
|
+
}
|
|
37
|
+
'''
|
|
38
|
+
|
|
39
|
+
# Create cache service
|
|
40
|
+
config = CacheConfig(
|
|
41
|
+
use_cache_control=True,
|
|
42
|
+
default_max_age=0, # No cache by default
|
|
43
|
+
)
|
|
44
|
+
cache_service = CacheService(
|
|
45
|
+
backend=InMemoryCacheBackend(),
|
|
46
|
+
key_builder=DefaultKeyBuilder(),
|
|
47
|
+
serializer=JsonSerializer(),
|
|
48
|
+
config=config,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Create extension with schema parsing
|
|
52
|
+
extension = CacheExtension(cache_service, schema=schema)
|
|
53
|
+
|
|
54
|
+
Dynamic cache hints in resolvers:
|
|
55
|
+
from cacheql.hints import set_cache_hint, private_cache
|
|
56
|
+
|
|
57
|
+
@query.field("user")
|
|
58
|
+
async def resolve_user(_, info, id: str):
|
|
59
|
+
user = await get_user(id)
|
|
60
|
+
set_cache_hint(info, max_age=300, scope="PRIVATE")
|
|
61
|
+
return user
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from cacheql.core.entities import (
|
|
65
|
+
CacheConfig,
|
|
66
|
+
CacheControlConfig,
|
|
67
|
+
CacheEntry,
|
|
68
|
+
CacheHint,
|
|
69
|
+
CacheKey,
|
|
70
|
+
CacheScope,
|
|
71
|
+
FieldCacheHint,
|
|
72
|
+
ResponseCachePolicy,
|
|
73
|
+
)
|
|
74
|
+
from cacheql.core.interfaces import (
|
|
75
|
+
ICacheBackend,
|
|
76
|
+
IInvalidator,
|
|
77
|
+
IKeyBuilder,
|
|
78
|
+
ISerializer,
|
|
79
|
+
)
|
|
80
|
+
from cacheql.core.services import (
|
|
81
|
+
CACHE_CONTROL_DIRECTIVE,
|
|
82
|
+
CacheControlCalculator,
|
|
83
|
+
CacheControlContext,
|
|
84
|
+
CacheService,
|
|
85
|
+
DirectiveParser,
|
|
86
|
+
SchemaDirectives,
|
|
87
|
+
create_cache_control_context,
|
|
88
|
+
get_cache_control_directive_sdl,
|
|
89
|
+
)
|
|
90
|
+
from cacheql.decorators import cached, configure, invalidates
|
|
91
|
+
from cacheql.infrastructure import (
|
|
92
|
+
DefaultKeyBuilder,
|
|
93
|
+
InMemoryCacheBackend,
|
|
94
|
+
JsonSerializer,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
__version__ = "0.1.0"
|
|
98
|
+
|
|
99
|
+
__all__ = [
|
|
100
|
+
# Version
|
|
101
|
+
"__version__",
|
|
102
|
+
# Core entities
|
|
103
|
+
"CacheConfig",
|
|
104
|
+
"CacheEntry",
|
|
105
|
+
"CacheKey",
|
|
106
|
+
# Cache control (Apollo-style)
|
|
107
|
+
"CacheHint",
|
|
108
|
+
"CacheScope",
|
|
109
|
+
"FieldCacheHint",
|
|
110
|
+
"ResponseCachePolicy",
|
|
111
|
+
"CacheControlConfig",
|
|
112
|
+
"CacheControlCalculator",
|
|
113
|
+
"CacheControlContext",
|
|
114
|
+
"create_cache_control_context",
|
|
115
|
+
# Directive parsing
|
|
116
|
+
"DirectiveParser",
|
|
117
|
+
"SchemaDirectives",
|
|
118
|
+
"CACHE_CONTROL_DIRECTIVE",
|
|
119
|
+
"get_cache_control_directive_sdl",
|
|
120
|
+
# Core interfaces
|
|
121
|
+
"ICacheBackend",
|
|
122
|
+
"IKeyBuilder",
|
|
123
|
+
"ISerializer",
|
|
124
|
+
"IInvalidator",
|
|
125
|
+
# Core services
|
|
126
|
+
"CacheService",
|
|
127
|
+
# Infrastructure implementations
|
|
128
|
+
"InMemoryCacheBackend",
|
|
129
|
+
"DefaultKeyBuilder",
|
|
130
|
+
"JsonSerializer",
|
|
131
|
+
# Decorators
|
|
132
|
+
"cached",
|
|
133
|
+
"invalidates",
|
|
134
|
+
"configure",
|
|
135
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework adapters for cacheql."""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Ariadne framework adapter for cacheql."""
|
|
2
|
+
|
|
3
|
+
from cacheql.adapters.ariadne.decorators import cached_resolver, invalidates_cache
|
|
4
|
+
from cacheql.adapters.ariadne.extension import CacheExtension, create_cache_extension
|
|
5
|
+
from cacheql.adapters.ariadne.graphql import CachingGraphQL
|
|
6
|
+
from cacheql.adapters.ariadne.handler import CachingGraphQLHTTPHandler
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
# Recommended: Simple drop-in replacement for GraphQL
|
|
10
|
+
"CachingGraphQL",
|
|
11
|
+
"CachingGraphQLHTTPHandler",
|
|
12
|
+
# Deprecated: Extension-based approach (doesn't work with async)
|
|
13
|
+
"CacheExtension",
|
|
14
|
+
"create_cache_extension",
|
|
15
|
+
# Decorators for resolver-level caching
|
|
16
|
+
"cached_resolver",
|
|
17
|
+
"invalidates_cache",
|
|
18
|
+
]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Ariadne-specific decorators for field-level caching."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from cacheql.core.services.cache_service import CacheService
|
|
10
|
+
from cacheql.infrastructure.key_builders.default import DefaultKeyBuilder
|
|
11
|
+
|
|
12
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
13
|
+
|
|
14
|
+
# Global cache service reference for decorators
|
|
15
|
+
_cache_service: CacheService | None = None
|
|
16
|
+
_key_builder: DefaultKeyBuilder | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def configure_cache(cache_service: CacheService) -> None:
|
|
20
|
+
"""Configure the global cache service for decorators.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
cache_service: The cache service to use.
|
|
24
|
+
"""
|
|
25
|
+
global _cache_service, _key_builder
|
|
26
|
+
_cache_service = cache_service
|
|
27
|
+
_key_builder = DefaultKeyBuilder(prefix=cache_service.config.key_prefix)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cached_resolver(
|
|
31
|
+
ttl: timedelta | None = None,
|
|
32
|
+
tags: list[str] | None = None,
|
|
33
|
+
key: str | Callable[..., str] | None = None,
|
|
34
|
+
) -> Callable[[F], F]:
|
|
35
|
+
"""Decorator for caching resolver results.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
@query.field("user")
|
|
39
|
+
@cached_resolver(ttl=timedelta(minutes=10), tags=["User"])
|
|
40
|
+
async def resolve_user(_, info, id: str):
|
|
41
|
+
return await get_user(id)
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
ttl: Time-to-live for cached results.
|
|
45
|
+
tags: Tags for cache invalidation.
|
|
46
|
+
key: Custom key or key builder function.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Decorated resolver function.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def decorator(func: F) -> F:
|
|
53
|
+
@functools.wraps(func)
|
|
54
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
55
|
+
if _cache_service is None or _key_builder is None:
|
|
56
|
+
# Cache not configured, execute directly
|
|
57
|
+
return await func(*args, **kwargs)
|
|
58
|
+
|
|
59
|
+
# Build cache key
|
|
60
|
+
cache_key = _build_cache_key(func, args, kwargs, key)
|
|
61
|
+
|
|
62
|
+
# Try to get from cache
|
|
63
|
+
cached_data = await _cache_service._backend.get(cache_key)
|
|
64
|
+
if cached_data is not None:
|
|
65
|
+
return _cache_service._serializer.deserialize(cached_data)
|
|
66
|
+
|
|
67
|
+
# Execute resolver
|
|
68
|
+
result = await func(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
# Cache result
|
|
71
|
+
effective_ttl = ttl or _cache_service.config.default_ttl
|
|
72
|
+
serialized = _cache_service._serializer.serialize(result)
|
|
73
|
+
await _cache_service._backend.set(cache_key, serialized, effective_ttl)
|
|
74
|
+
|
|
75
|
+
# Store tag mappings
|
|
76
|
+
resolved_tags = _resolve_tags(tags, args, kwargs)
|
|
77
|
+
if resolved_tags:
|
|
78
|
+
for tag in resolved_tags:
|
|
79
|
+
prefix = _cache_service.config.key_prefix
|
|
80
|
+
tag_key = f"{prefix}:tag:{tag}:{cache_key}"
|
|
81
|
+
await _cache_service._backend.set(
|
|
82
|
+
tag_key, cache_key.encode(), effective_ttl
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
return wrapper # type: ignore
|
|
88
|
+
|
|
89
|
+
return decorator
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def invalidates_cache(
|
|
93
|
+
tags: list[str] | None = None,
|
|
94
|
+
) -> Callable[[F], F]:
|
|
95
|
+
"""Decorator for invalidating cache on mutation.
|
|
96
|
+
|
|
97
|
+
Usage:
|
|
98
|
+
@mutation.field("updateUser")
|
|
99
|
+
@invalidates_cache(tags=["User", "User:{id}"])
|
|
100
|
+
async def resolve_update_user(_, info, id: str, input: dict):
|
|
101
|
+
return await update_user(id, input)
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
tags: Tags to invalidate. Supports {arg_name} interpolation.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Decorated resolver function.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def decorator(func: F) -> F:
|
|
111
|
+
@functools.wraps(func)
|
|
112
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
113
|
+
# Execute mutation first
|
|
114
|
+
result = await func(*args, **kwargs)
|
|
115
|
+
|
|
116
|
+
# Invalidate cache
|
|
117
|
+
if _cache_service is not None and tags:
|
|
118
|
+
resolved_tags = _resolve_tags(tags, args, kwargs)
|
|
119
|
+
await _cache_service.invalidate(resolved_tags)
|
|
120
|
+
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
return wrapper # type: ignore
|
|
124
|
+
|
|
125
|
+
return decorator
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_cache_key(
|
|
129
|
+
func: Callable[..., Any],
|
|
130
|
+
args: tuple[Any, ...],
|
|
131
|
+
kwargs: dict[str, Any],
|
|
132
|
+
custom_key: str | Callable[..., str] | None,
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Build cache key for a resolver call.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
func: The resolver function.
|
|
138
|
+
args: Positional arguments.
|
|
139
|
+
kwargs: Keyword arguments.
|
|
140
|
+
custom_key: Custom key or key builder function.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The cache key string.
|
|
144
|
+
"""
|
|
145
|
+
if _key_builder is None:
|
|
146
|
+
raise RuntimeError("Cache not configured. Call configure_cache() first.")
|
|
147
|
+
|
|
148
|
+
if custom_key is not None:
|
|
149
|
+
if callable(custom_key):
|
|
150
|
+
return custom_key(*args, **kwargs)
|
|
151
|
+
return _interpolate_string(custom_key, args, kwargs)
|
|
152
|
+
|
|
153
|
+
# Build default key from function name and arguments
|
|
154
|
+
func_name = func.__name__
|
|
155
|
+
type_name = _get_type_name_from_func(func)
|
|
156
|
+
|
|
157
|
+
return _key_builder.build_field_key(
|
|
158
|
+
type_name=type_name,
|
|
159
|
+
field_name=func_name,
|
|
160
|
+
args=kwargs if kwargs else None,
|
|
161
|
+
parent_value=args[0] if args else None,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _get_type_name_from_func(func: Callable[..., Any]) -> str:
|
|
166
|
+
"""Extract GraphQL type name from function.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
func: The resolver function.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The type name or "Query" as default.
|
|
173
|
+
"""
|
|
174
|
+
# Try to get from function metadata
|
|
175
|
+
if hasattr(func, "_graphql_type"):
|
|
176
|
+
return str(func._graphql_type)
|
|
177
|
+
|
|
178
|
+
# Infer from function name
|
|
179
|
+
name = func.__name__
|
|
180
|
+
if name.startswith("resolve_"):
|
|
181
|
+
return "Query"
|
|
182
|
+
|
|
183
|
+
return "Query"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _resolve_tags(
|
|
187
|
+
tags: list[str] | None,
|
|
188
|
+
args: tuple[Any, ...],
|
|
189
|
+
kwargs: dict[str, Any],
|
|
190
|
+
) -> list[str]:
|
|
191
|
+
"""Resolve tags with argument interpolation.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
tags: Tag patterns with optional {arg} placeholders.
|
|
195
|
+
args: Positional arguments.
|
|
196
|
+
kwargs: Keyword arguments.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of resolved tag strings.
|
|
200
|
+
"""
|
|
201
|
+
if not tags:
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
resolved: list[str] = []
|
|
205
|
+
for tag in tags:
|
|
206
|
+
resolved_tag = _interpolate_string(tag, args, kwargs)
|
|
207
|
+
resolved.append(resolved_tag)
|
|
208
|
+
|
|
209
|
+
return resolved
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _interpolate_string(
|
|
213
|
+
template: str,
|
|
214
|
+
args: tuple[Any, ...],
|
|
215
|
+
kwargs: dict[str, Any],
|
|
216
|
+
) -> str:
|
|
217
|
+
"""Interpolate {arg_name} placeholders in string.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
template: String with {arg_name} placeholders.
|
|
221
|
+
args: Positional arguments (ignored for interpolation).
|
|
222
|
+
kwargs: Keyword arguments for interpolation.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Interpolated string.
|
|
226
|
+
"""
|
|
227
|
+
# Find all {name} patterns
|
|
228
|
+
pattern = r"\{(\w+)\}"
|
|
229
|
+
|
|
230
|
+
def replacer(match: re.Match[str]) -> str:
|
|
231
|
+
name = match.group(1)
|
|
232
|
+
if name in kwargs:
|
|
233
|
+
return str(kwargs[name])
|
|
234
|
+
return match.group(0) # Keep original if not found
|
|
235
|
+
|
|
236
|
+
return re.sub(pattern, replacer, template)
|