spakky-cache 6.3.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.
- spakky/cache/__init__.py +36 -0
- spakky/cache/annotation.py +54 -0
- spakky/cache/aspects/__init__.py +5 -0
- spakky/cache/aspects/cache_aspect.py +114 -0
- spakky/cache/backends/__init__.py +5 -0
- spakky/cache/backends/memory.py +86 -0
- spakky/cache/error.py +23 -0
- spakky/cache/interfaces/__init__.py +5 -0
- spakky/cache/interfaces/cache.py +56 -0
- spakky/cache/main.py +17 -0
- spakky/cache/py.typed +1 -0
- spakky/cache/result.py +23 -0
- spakky_cache-6.3.1.dist-info/METADATA +104 -0
- spakky_cache-6.3.1.dist-info/RECORD +16 -0
- spakky_cache-6.3.1.dist-info/WHEEL +4 -0
- spakky_cache-6.3.1.dist-info/entry_points.txt +3 -0
spakky/cache/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Backend-neutral application data cache contracts."""
|
|
2
|
+
|
|
3
|
+
from spakky.core.application.plugin import Plugin
|
|
4
|
+
|
|
5
|
+
from spakky.cache.annotation import CacheEvict, Cacheable, cache_evict, cacheable
|
|
6
|
+
from spakky.cache.aspects.cache_aspect import AsyncCacheAspect, CacheAspect
|
|
7
|
+
from spakky.cache.backends.memory import InMemoryCache
|
|
8
|
+
from spakky.cache.error import (
|
|
9
|
+
AbstractSpakkyCacheError,
|
|
10
|
+
CacheKeyGenerationError,
|
|
11
|
+
InvalidCacheTTLError,
|
|
12
|
+
)
|
|
13
|
+
from spakky.cache.interfaces.cache import ICache, CacheTTL
|
|
14
|
+
from spakky.cache.result import CacheHit, CacheMiss, CacheResult
|
|
15
|
+
|
|
16
|
+
PLUGIN_NAME = Plugin(name="spakky-cache")
|
|
17
|
+
"""Plugin identifier for the Spakky Cache package."""
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ICache",
|
|
21
|
+
"AbstractSpakkyCacheError",
|
|
22
|
+
"AsyncCacheAspect",
|
|
23
|
+
"CacheEvict",
|
|
24
|
+
"CacheHit",
|
|
25
|
+
"CacheKeyGenerationError",
|
|
26
|
+
"CacheMiss",
|
|
27
|
+
"CacheResult",
|
|
28
|
+
"CacheTTL",
|
|
29
|
+
"CacheAspect",
|
|
30
|
+
"Cacheable",
|
|
31
|
+
"InMemoryCache",
|
|
32
|
+
"InvalidCacheTTLError",
|
|
33
|
+
"PLUGIN_NAME",
|
|
34
|
+
"cache_evict",
|
|
35
|
+
"cacheable",
|
|
36
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Cache method annotations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable, ParamSpec, TypeVar
|
|
5
|
+
|
|
6
|
+
from spakky.core.common.annotation import FunctionAnnotation
|
|
7
|
+
|
|
8
|
+
from spakky.cache.interfaces.cache import CacheTTL
|
|
9
|
+
|
|
10
|
+
P = ParamSpec("P")
|
|
11
|
+
R = TypeVar("R")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Cacheable(FunctionAnnotation):
|
|
16
|
+
"""Annotation for caching method return values."""
|
|
17
|
+
|
|
18
|
+
key: str | None = None
|
|
19
|
+
"""Optional format string used as the cache key."""
|
|
20
|
+
|
|
21
|
+
ttl: CacheTTL = None
|
|
22
|
+
"""Optional cache entry TTL."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CacheEvict(FunctionAnnotation):
|
|
27
|
+
"""Annotation for evicting a cache entry after successful method execution."""
|
|
28
|
+
|
|
29
|
+
key: str | None = None
|
|
30
|
+
"""Optional format string used as the cache key."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cacheable(
|
|
34
|
+
key: str | None = None,
|
|
35
|
+
*,
|
|
36
|
+
ttl: CacheTTL = None,
|
|
37
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
38
|
+
"""Decorate a method so its return value is cached by AOP."""
|
|
39
|
+
|
|
40
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
41
|
+
return Cacheable(key=key, ttl=ttl)(func)
|
|
42
|
+
|
|
43
|
+
return decorator
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cache_evict(
|
|
47
|
+
key: str | None = None,
|
|
48
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
49
|
+
"""Decorate a method so its cache entry is evicted after success."""
|
|
50
|
+
|
|
51
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
52
|
+
return CacheEvict(key=key)(func)
|
|
53
|
+
|
|
54
|
+
return decorator
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""AOP aspects for cache annotations."""
|
|
2
|
+
|
|
3
|
+
from inspect import iscoroutinefunction
|
|
4
|
+
|
|
5
|
+
from spakky.core.aop.aspect import Aspect, AsyncAspect
|
|
6
|
+
from spakky.core.aop.interfaces.aspect import IAspect, IAsyncAspect
|
|
7
|
+
from spakky.core.aop.pointcut import Around
|
|
8
|
+
from spakky.core.common.types import AsyncFunc, Func
|
|
9
|
+
from spakky.core.pod.annotations.order import Order
|
|
10
|
+
|
|
11
|
+
from spakky.cache.annotation import CacheEvict, Cacheable
|
|
12
|
+
from spakky.cache.error import CacheKeyGenerationError
|
|
13
|
+
from spakky.cache.interfaces.cache import ICache
|
|
14
|
+
from spakky.cache.result import CacheHit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _matches_sync(method: Func) -> bool:
|
|
18
|
+
return (
|
|
19
|
+
Cacheable.exists(method) or CacheEvict.exists(method)
|
|
20
|
+
) and not iscoroutinefunction(method)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _matches_async(method: Func) -> bool:
|
|
24
|
+
return (
|
|
25
|
+
Cacheable.exists(method) or CacheEvict.exists(method)
|
|
26
|
+
) and iscoroutinefunction(method)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _key_from_call(
|
|
30
|
+
joinpoint: Func,
|
|
31
|
+
configured_key: str | None,
|
|
32
|
+
args: tuple[object, ...],
|
|
33
|
+
kwargs: dict[str, object],
|
|
34
|
+
) -> str:
|
|
35
|
+
try:
|
|
36
|
+
if configured_key is not None:
|
|
37
|
+
return configured_key.format(*args, **kwargs)
|
|
38
|
+
ordered_kwargs = tuple(sorted(kwargs.items(), key=lambda item: item[0]))
|
|
39
|
+
return (
|
|
40
|
+
f"{joinpoint.__module__}.{joinpoint.__qualname__}:"
|
|
41
|
+
f"args={args!r}:kwargs={ordered_kwargs!r}"
|
|
42
|
+
)
|
|
43
|
+
except Exception as error:
|
|
44
|
+
raise CacheKeyGenerationError() from error
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@Order(0)
|
|
48
|
+
@Aspect()
|
|
49
|
+
class CacheAspect(IAspect):
|
|
50
|
+
"""Aspect that applies cacheable and cache eviction annotations."""
|
|
51
|
+
|
|
52
|
+
_cache: ICache[object]
|
|
53
|
+
|
|
54
|
+
def __init__(self, cache: ICache[object]) -> None:
|
|
55
|
+
self._cache = cache
|
|
56
|
+
|
|
57
|
+
@Around(_matches_sync)
|
|
58
|
+
def around(
|
|
59
|
+
self,
|
|
60
|
+
joinpoint: Func,
|
|
61
|
+
*args: object,
|
|
62
|
+
**kwargs: object,
|
|
63
|
+
) -> object:
|
|
64
|
+
evict = CacheEvict.get_or_none(joinpoint)
|
|
65
|
+
if evict is not None:
|
|
66
|
+
result = joinpoint(*args, **kwargs)
|
|
67
|
+
self._cache.delete(_key_from_call(joinpoint, evict.key, args, kwargs))
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
cacheable = Cacheable.get(joinpoint)
|
|
71
|
+
key = _key_from_call(joinpoint, cacheable.key, args, kwargs)
|
|
72
|
+
cached = self._cache.get(key)
|
|
73
|
+
if isinstance(cached, CacheHit):
|
|
74
|
+
return cached.value
|
|
75
|
+
|
|
76
|
+
result = joinpoint(*args, **kwargs)
|
|
77
|
+
self._cache.set(key, result, ttl=cacheable.ttl)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@Order(0)
|
|
82
|
+
@AsyncAspect()
|
|
83
|
+
class AsyncCacheAspect(IAsyncAspect):
|
|
84
|
+
"""Async aspect that applies cacheable and cache eviction annotations."""
|
|
85
|
+
|
|
86
|
+
_cache: ICache[object]
|
|
87
|
+
|
|
88
|
+
def __init__(self, cache: ICache[object]) -> None:
|
|
89
|
+
self._cache = cache
|
|
90
|
+
|
|
91
|
+
@Around(_matches_async)
|
|
92
|
+
async def around_async(
|
|
93
|
+
self,
|
|
94
|
+
joinpoint: AsyncFunc,
|
|
95
|
+
*args: object,
|
|
96
|
+
**kwargs: object,
|
|
97
|
+
) -> object:
|
|
98
|
+
evict = CacheEvict.get_or_none(joinpoint)
|
|
99
|
+
if evict is not None:
|
|
100
|
+
result = await joinpoint(*args, **kwargs)
|
|
101
|
+
await self._cache.delete_async(
|
|
102
|
+
_key_from_call(joinpoint, evict.key, args, kwargs)
|
|
103
|
+
)
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
cacheable = Cacheable.get(joinpoint)
|
|
107
|
+
key = _key_from_call(joinpoint, cacheable.key, args, kwargs)
|
|
108
|
+
cached = await self._cache.get_async(key)
|
|
109
|
+
if isinstance(cached, CacheHit):
|
|
110
|
+
return cached.value
|
|
111
|
+
|
|
112
|
+
result = await joinpoint(*args, **kwargs)
|
|
113
|
+
await self._cache.set_async(key, result, ttl=cacheable.ttl)
|
|
114
|
+
return result
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""In-memory cache backend."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from time import monotonic
|
|
6
|
+
from typing import Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from spakky.cache.error import InvalidCacheTTLError
|
|
11
|
+
from spakky.cache.interfaces.cache import ICache, CacheTTL
|
|
12
|
+
from spakky.cache.result import CacheHit, CacheMiss, CacheResult
|
|
13
|
+
from spakky.core.common.mutability import immutable
|
|
14
|
+
from spakky.core.pod.annotations.pod import Pod
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@immutable
|
|
20
|
+
class _CacheEntry(Generic[T]):
|
|
21
|
+
value: T
|
|
22
|
+
expires_at: float | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@Pod()
|
|
26
|
+
class InMemoryCache(ICache[T]):
|
|
27
|
+
"""Deterministic in-memory cache backend for one process."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, clock: Callable[[], float] = monotonic) -> None:
|
|
30
|
+
self._clock = clock
|
|
31
|
+
self._entries: dict[str, _CacheEntry[T]] = {}
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def get(self, key: str) -> CacheResult[T]:
|
|
35
|
+
entry = self._entries.get(key)
|
|
36
|
+
if entry is None:
|
|
37
|
+
return CacheMiss()
|
|
38
|
+
if self._is_expired(entry):
|
|
39
|
+
self._entries.pop(key)
|
|
40
|
+
return CacheMiss()
|
|
41
|
+
return CacheHit(value=entry.value)
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
def set(self, key: str, value: T, *, ttl: CacheTTL = None) -> None:
|
|
45
|
+
expires_at = self._compute_expires_at(ttl)
|
|
46
|
+
self._entries[key] = _CacheEntry(value=value, expires_at=expires_at)
|
|
47
|
+
|
|
48
|
+
@override
|
|
49
|
+
def delete(self, key: str) -> bool:
|
|
50
|
+
return self._entries.pop(key, None) is not None
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
def clear(self) -> None:
|
|
54
|
+
self._entries.clear()
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
async def get_async(self, key: str) -> CacheResult[T]:
|
|
58
|
+
return self.get(key)
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
async def set_async(self, key: str, value: T, *, ttl: CacheTTL = None) -> None:
|
|
62
|
+
self.set(key, value, ttl=ttl)
|
|
63
|
+
|
|
64
|
+
@override
|
|
65
|
+
async def delete_async(self, key: str) -> bool:
|
|
66
|
+
return self.delete(key)
|
|
67
|
+
|
|
68
|
+
@override
|
|
69
|
+
async def clear_async(self) -> None:
|
|
70
|
+
self.clear()
|
|
71
|
+
|
|
72
|
+
def _compute_expires_at(self, ttl: CacheTTL) -> float | None:
|
|
73
|
+
if ttl is None:
|
|
74
|
+
return None
|
|
75
|
+
if isinstance(ttl, timedelta):
|
|
76
|
+
ttl_seconds = ttl.total_seconds()
|
|
77
|
+
else:
|
|
78
|
+
ttl_seconds = float(ttl)
|
|
79
|
+
if ttl_seconds <= 0:
|
|
80
|
+
raise InvalidCacheTTLError()
|
|
81
|
+
return self._clock() + ttl_seconds
|
|
82
|
+
|
|
83
|
+
def _is_expired(self, entry: _CacheEntry[T]) -> bool:
|
|
84
|
+
if entry.expires_at is None:
|
|
85
|
+
return False
|
|
86
|
+
return self._clock() >= entry.expires_at
|
spakky/cache/error.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Error classes for the spakky-cache package."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
|
|
5
|
+
from spakky.core.common.error import AbstractSpakkyFrameworkError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AbstractSpakkyCacheError(AbstractSpakkyFrameworkError, ABC):
|
|
9
|
+
"""Base class for cache-related errors."""
|
|
10
|
+
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidCacheTTLError(AbstractSpakkyCacheError):
|
|
15
|
+
"""Raised when a cache entry is written with an invalid TTL."""
|
|
16
|
+
|
|
17
|
+
message = "Cache TTL must be positive"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CacheKeyGenerationError(AbstractSpakkyCacheError):
|
|
21
|
+
"""Raised when a cache annotation cannot produce a deterministic key."""
|
|
22
|
+
|
|
23
|
+
message = "Cache key generation failed"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Backend-neutral cache contract."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from typing import Generic, TypeAlias, TypeVar
|
|
6
|
+
|
|
7
|
+
from spakky.cache.result import CacheResult
|
|
8
|
+
|
|
9
|
+
CacheTTL: TypeAlias = float | int | timedelta | None
|
|
10
|
+
"""Cache entry lifetime. None means no automatic expiry."""
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ICache(ABC, Generic[T]):
|
|
16
|
+
"""Backend-neutral application data cache contract."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def get(self, key: str) -> CacheResult[T]:
|
|
20
|
+
"""Return a typed hit or miss result for a cache key."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def set(self, key: str, value: T, *, ttl: CacheTTL = None) -> None:
|
|
25
|
+
"""Store a cache value with an optional TTL."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def delete(self, key: str) -> bool:
|
|
30
|
+
"""Remove one cache key and return whether an entry existed."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def clear(self) -> None:
|
|
35
|
+
"""Remove all cache entries from this backend instance."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def get_async(self, key: str) -> CacheResult[T]:
|
|
40
|
+
"""Async variant of get."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def set_async(self, key: str, value: T, *, ttl: CacheTTL = None) -> None:
|
|
45
|
+
"""Async variant of set."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def delete_async(self, key: str) -> bool:
|
|
50
|
+
"""Async variant of delete."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def clear_async(self) -> None:
|
|
55
|
+
"""Async variant of clear."""
|
|
56
|
+
...
|
spakky/cache/main.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Plugin initialization entry point."""
|
|
2
|
+
|
|
3
|
+
from spakky.core.application.application import SpakkyApplication
|
|
4
|
+
|
|
5
|
+
from spakky.cache.aspects.cache_aspect import AsyncCacheAspect, CacheAspect
|
|
6
|
+
from spakky.cache.backends.memory import InMemoryCache
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def initialize(app: SpakkyApplication) -> None:
|
|
10
|
+
"""Initialize the spakky-cache plugin.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
app: The SpakkyApplication instance.
|
|
14
|
+
"""
|
|
15
|
+
app.add(InMemoryCache)
|
|
16
|
+
app.add(CacheAspect)
|
|
17
|
+
app.add(AsyncCacheAspect)
|
spakky/cache/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
spakky/cache/result.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Typed cache result contracts."""
|
|
2
|
+
|
|
3
|
+
from typing import Generic, TypeAlias, TypeVar
|
|
4
|
+
|
|
5
|
+
from spakky.core.common.mutability import immutable
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@immutable
|
|
11
|
+
class CacheHit(Generic[T]):
|
|
12
|
+
"""Cache lookup result for an existing entry."""
|
|
13
|
+
|
|
14
|
+
value: T
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@immutable
|
|
18
|
+
class CacheMiss:
|
|
19
|
+
"""Cache lookup result for a missing or expired entry."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
CacheResult: TypeAlias = CacheHit[T] | CacheMiss
|
|
23
|
+
"""Cache lookup result type."""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: spakky-cache
|
|
3
|
+
Version: 6.3.1
|
|
4
|
+
Summary: Backend-neutral cache contracts and in-memory backend for Spakky Framework
|
|
5
|
+
Author: Spakky
|
|
6
|
+
Author-email: Spakky <sejong418@icloud.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: spakky>=6.3.1
|
|
9
|
+
Requires-Dist: typing-extensions>=4.15.0
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Spakky Cache
|
|
14
|
+
|
|
15
|
+
Backend-neutral application data cache contracts for [Spakky Framework](https://github.com/E5presso/spakky-framework).
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install spakky-cache
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- **Typed cache results**: `CacheHit[T]` and `CacheMiss` represent hit and miss outcomes without backend-specific exceptions.
|
|
26
|
+
- **Sync and async contracts**: `ICache[T]` defines `get`, `set`, `delete`, `clear` and async equivalents.
|
|
27
|
+
- **TTL semantics**: Positive TTL values expire entries deterministically; missing and expired entries are misses.
|
|
28
|
+
- **In-memory backend**: `InMemoryCache[T]` is suitable for local development, tests, and single-process usage.
|
|
29
|
+
- **AOP method caching**: `@cacheable()` and `@cache_evict()` apply cache hit/miss and eviction behavior without manual plumbing.
|
|
30
|
+
- **Application data scope**: This package does not expose or mutate `ApplicationContext` internal caches.
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from datetime import timedelta
|
|
36
|
+
|
|
37
|
+
from spakky.cache import CacheHit, InMemoryCache
|
|
38
|
+
|
|
39
|
+
cache = InMemoryCache[str]()
|
|
40
|
+
cache.set("profile:42", "Ada", ttl=timedelta(minutes=5))
|
|
41
|
+
|
|
42
|
+
result = cache.get("profile:42")
|
|
43
|
+
if isinstance(result, CacheHit):
|
|
44
|
+
print(result.value)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Annotation Usage
|
|
48
|
+
|
|
49
|
+
Load the `spakky-cache` plugin, then annotate service methods. The plugin registers `InMemoryCache`, `CacheAspect`, and `AsyncCacheAspect` so sync and async methods are handled through Spakky AOP.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from datetime import timedelta
|
|
53
|
+
|
|
54
|
+
from spakky.cache import cache_evict, cacheable
|
|
55
|
+
from spakky.core.stereotype.usecase import UseCase
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@UseCase()
|
|
59
|
+
class ProfileService:
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self.calls = 0
|
|
62
|
+
|
|
63
|
+
@cacheable(key="profile:{0}", ttl=timedelta(minutes=5))
|
|
64
|
+
def load_profile(self, user_id: str) -> str:
|
|
65
|
+
self.calls += 1
|
|
66
|
+
return f"profile:{user_id}"
|
|
67
|
+
|
|
68
|
+
@cache_evict(key="profile:{0}")
|
|
69
|
+
def refresh_profile(self, user_id: str) -> None:
|
|
70
|
+
...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The default key is derived from the method module, qualified name, positional arguments, and sorted keyword arguments. Explicit `key` values are Python format strings evaluated against method call arguments. Invalid key formatting raises `CacheKeyGenerationError`. Backend failures are not swallowed; cache errors propagate loudly.
|
|
74
|
+
|
|
75
|
+
`@cache_evict()` deletes the matching entry only after the annotated method succeeds. Failed method calls leave existing entries untouched so a failed refresh does not erase the last known cached value.
|
|
76
|
+
|
|
77
|
+
## Async Usage
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from spakky.cache import CacheHit, InMemoryCache
|
|
81
|
+
|
|
82
|
+
cache = InMemoryCache[int]()
|
|
83
|
+
await cache.set_async("answer", 42)
|
|
84
|
+
|
|
85
|
+
result = await cache.get_async("answer")
|
|
86
|
+
if isinstance(result, CacheHit):
|
|
87
|
+
assert result.value == 42
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## TTL Rules
|
|
91
|
+
|
|
92
|
+
`ttl=None` stores an entry until explicit deletion or clear. Positive `float`, `int`, or `datetime.timedelta` values expire entries after that duration. Zero or negative TTL values raise `InvalidCacheTTLError`.
|
|
93
|
+
|
|
94
|
+
The in-memory backend deletes expired entries when they are observed. It is deterministic for one process and does not provide distributed invalidation, stampede protection, or tag-based eviction.
|
|
95
|
+
|
|
96
|
+
Cache eviction annotations remove a single matching entry only after the annotated method succeeds.
|
|
97
|
+
|
|
98
|
+
## Scope
|
|
99
|
+
|
|
100
|
+
`spakky-cache` is an application data cache abstraction. It is separate from `ApplicationContext` internal type, singleton, and context caches. Distributed locks, cache stampede protection, tag invalidation, write-through/write-behind policies, and metrics exporters are outside the current contract.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT License
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
spakky/cache/__init__.py,sha256=3N1cUz38XOeNjhVgbD0KKHTmA6q361FuleWlnHrs1VM,1004
|
|
2
|
+
spakky/cache/annotation.py,sha256=a2cT0vaScbQWiwiKBTbuuLzMyVucaXxJBicdk_8rO3Q,1351
|
|
3
|
+
spakky/cache/aspects/__init__.py,sha256=kOiyTjVzD5L2x1NgZvWE3_o3QPeL4JOyZGzZqpqWdtQ,149
|
|
4
|
+
spakky/cache/aspects/cache_aspect.py,sha256=L8z4M3XOnpt-mazpsVQnDpOjL8Nr1oCde0WA7mBlSI0,3492
|
|
5
|
+
spakky/cache/backends/__init__.py,sha256=oXu5OKj6oLSpT28Ls7qhHubAC8gMEouQo2So3G397Fg,122
|
|
6
|
+
spakky/cache/backends/memory.py,sha256=UjLPKruJOj_rzuU69a_uRMy3aMausKZjMmboIX7gxY4,2501
|
|
7
|
+
spakky/cache/error.py,sha256=leCffphg_1B744QAuh9lZdxzn5xAynLdrimgcTexUqk,612
|
|
8
|
+
spakky/cache/interfaces/__init__.py,sha256=GUqdIR5y9R1caBVChzI72NhMMTEsxzzmNxwzlYjvIRA,105
|
|
9
|
+
spakky/cache/interfaces/cache.py,sha256=geavi0cg8wwbUCuZUTZNtq1Qk6tsJBZm_GfkouZntwI,1519
|
|
10
|
+
spakky/cache/main.py,sha256=90wipFyW5fkeAZlDHY1YJKASdZkOfbgKlCQmvFAAGic,479
|
|
11
|
+
spakky/cache/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
12
|
+
spakky/cache/result.py,sha256=qTGT6y6gJmxcUTXxJaOB5HlLTR3zMS_4SLT-ly6WyX4,438
|
|
13
|
+
spakky_cache-6.3.1.dist-info/WHEEL,sha256=q5IF0q2xCp3ktUFRCVWsQLjl2ChNlWXBJtnI1LCGdJ8,80
|
|
14
|
+
spakky_cache-6.3.1.dist-info/entry_points.txt,sha256=t2Y0eAUwTAyU7ZUb0ph056eJD4sbgvt8dNCgQVrsB3s,62
|
|
15
|
+
spakky_cache-6.3.1.dist-info/METADATA,sha256=RyW0XrPBnhnK8afZBBdb2qR7BSV55P4clDeMCA7qCyo,3915
|
|
16
|
+
spakky_cache-6.3.1.dist-info/RECORD,,
|