spakky-redis 6.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spakky_redis-6.3.1/PKG-INFO +87 -0
- spakky_redis-6.3.1/README.md +74 -0
- spakky_redis-6.3.1/pyproject.toml +80 -0
- spakky_redis-6.3.1/src/spakky/plugins/redis/__init__.py +23 -0
- spakky_redis-6.3.1/src/spakky/plugins/redis/cache.py +367 -0
- spakky_redis-6.3.1/src/spakky/plugins/redis/common/__init__.py +1 -0
- spakky_redis-6.3.1/src/spakky/plugins/redis/common/config.py +57 -0
- spakky_redis-6.3.1/src/spakky/plugins/redis/error.py +21 -0
- spakky_redis-6.3.1/src/spakky/plugins/redis/main.py +12 -0
- spakky_redis-6.3.1/src/spakky/plugins/redis/py.typed +1 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: spakky-redis
|
|
3
|
+
Version: 6.3.1
|
|
4
|
+
Summary: Redis cache backend plugin for Spakky Framework
|
|
5
|
+
Author: Spakky
|
|
6
|
+
Author-email: Spakky <sejong418@icloud.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.13.1
|
|
9
|
+
Requires-Dist: redis>=7.1.0
|
|
10
|
+
Requires-Dist: spakky-cache>=6.3.1
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Spakky Redis
|
|
15
|
+
|
|
16
|
+
Redis cache backend plugin for [Spakky Framework](https://github.com/E5presso/spakky-framework).
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install spakky-redis
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Shared cache backend**: `RedisCache[T]` stores values in Redis so multiple process instances can observe the same entries.
|
|
27
|
+
- **Core contract compatibility**: Implements `spakky-cache` sync and async `ICache[T]` paths.
|
|
28
|
+
- **TTL semantics**: Positive TTL values are translated into Redis millisecond expiry; missing and expired keys return `CacheMiss`.
|
|
29
|
+
- **Loud failures**: Redis connection/configuration failures and serialization failures are raised as Spakky cache errors.
|
|
30
|
+
- **Scoped clear**: `clear` removes keys under the configured prefix instead of flushing the Redis database.
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from datetime import timedelta
|
|
36
|
+
|
|
37
|
+
from spakky.cache import CacheHit
|
|
38
|
+
from spakky.plugins.redis import RedisCache
|
|
39
|
+
|
|
40
|
+
cache = RedisCache[str]()
|
|
41
|
+
cache.set("profile:42", "Ada", ttl=timedelta(minutes=5))
|
|
42
|
+
|
|
43
|
+
result = cache.get("profile:42")
|
|
44
|
+
if isinstance(result, CacheHit):
|
|
45
|
+
print(result.value)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
`RedisCacheConfig` follows the standard Spakky `@Configuration` pattern and reads environment variables with the `SPAKKY_REDIS__` prefix.
|
|
51
|
+
|
|
52
|
+
| Environment variable | Default |
|
|
53
|
+
|----------------------|---------|
|
|
54
|
+
| `SPAKKY_REDIS__HOST` | `localhost` |
|
|
55
|
+
| `SPAKKY_REDIS__PORT` | `6379` |
|
|
56
|
+
| `SPAKKY_REDIS__DB` | `0` |
|
|
57
|
+
| `SPAKKY_REDIS__USERNAME` | unset |
|
|
58
|
+
| `SPAKKY_REDIS__PASSWORD` | unset |
|
|
59
|
+
| `SPAKKY_REDIS__USE_SSL` | `false` |
|
|
60
|
+
| `SPAKKY_REDIS__KEY_PREFIX` | `spakky:cache:` |
|
|
61
|
+
| `SPAKKY_REDIS__SOCKET_TIMEOUT` | `5.0` |
|
|
62
|
+
|
|
63
|
+
## Async Usage
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from spakky.cache import CacheHit
|
|
67
|
+
from spakky.plugins.redis import RedisCache
|
|
68
|
+
|
|
69
|
+
cache = RedisCache[int]()
|
|
70
|
+
await cache.set_async("answer", 42)
|
|
71
|
+
|
|
72
|
+
result = await cache.get_async("answer")
|
|
73
|
+
if isinstance(result, CacheHit):
|
|
74
|
+
assert result.value == 42
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Contract Notes
|
|
78
|
+
|
|
79
|
+
`RedisCache` is a backend implementation of the `spakky-cache` application data cache contract. Business code should depend on `ICache[T]` and can switch between `InMemoryCache` and `RedisCache` without changing cache hit/miss handling.
|
|
80
|
+
|
|
81
|
+
Values are serialized with pickle and stored under `SPAKKY_REDIS__KEY_PREFIX`. `clear()` and `clear_async()` delete only keys under that prefix. Redis failures, unexpected response types, and serialization failures are raised as Spakky cache errors instead of being treated as cache misses.
|
|
82
|
+
|
|
83
|
+
This plugin does not add distributed locks, cache stampede protection, tag invalidation, write-through/write-behind policies, or metrics exporters.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT License
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Spakky Redis
|
|
2
|
+
|
|
3
|
+
Redis cache backend plugin for [Spakky Framework](https://github.com/E5presso/spakky-framework).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install spakky-redis
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Shared cache backend**: `RedisCache[T]` stores values in Redis so multiple process instances can observe the same entries.
|
|
14
|
+
- **Core contract compatibility**: Implements `spakky-cache` sync and async `ICache[T]` paths.
|
|
15
|
+
- **TTL semantics**: Positive TTL values are translated into Redis millisecond expiry; missing and expired keys return `CacheMiss`.
|
|
16
|
+
- **Loud failures**: Redis connection/configuration failures and serialization failures are raised as Spakky cache errors.
|
|
17
|
+
- **Scoped clear**: `clear` removes keys under the configured prefix instead of flushing the Redis database.
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from datetime import timedelta
|
|
23
|
+
|
|
24
|
+
from spakky.cache import CacheHit
|
|
25
|
+
from spakky.plugins.redis import RedisCache
|
|
26
|
+
|
|
27
|
+
cache = RedisCache[str]()
|
|
28
|
+
cache.set("profile:42", "Ada", ttl=timedelta(minutes=5))
|
|
29
|
+
|
|
30
|
+
result = cache.get("profile:42")
|
|
31
|
+
if isinstance(result, CacheHit):
|
|
32
|
+
print(result.value)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
`RedisCacheConfig` follows the standard Spakky `@Configuration` pattern and reads environment variables with the `SPAKKY_REDIS__` prefix.
|
|
38
|
+
|
|
39
|
+
| Environment variable | Default |
|
|
40
|
+
|----------------------|---------|
|
|
41
|
+
| `SPAKKY_REDIS__HOST` | `localhost` |
|
|
42
|
+
| `SPAKKY_REDIS__PORT` | `6379` |
|
|
43
|
+
| `SPAKKY_REDIS__DB` | `0` |
|
|
44
|
+
| `SPAKKY_REDIS__USERNAME` | unset |
|
|
45
|
+
| `SPAKKY_REDIS__PASSWORD` | unset |
|
|
46
|
+
| `SPAKKY_REDIS__USE_SSL` | `false` |
|
|
47
|
+
| `SPAKKY_REDIS__KEY_PREFIX` | `spakky:cache:` |
|
|
48
|
+
| `SPAKKY_REDIS__SOCKET_TIMEOUT` | `5.0` |
|
|
49
|
+
|
|
50
|
+
## Async Usage
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from spakky.cache import CacheHit
|
|
54
|
+
from spakky.plugins.redis import RedisCache
|
|
55
|
+
|
|
56
|
+
cache = RedisCache[int]()
|
|
57
|
+
await cache.set_async("answer", 42)
|
|
58
|
+
|
|
59
|
+
result = await cache.get_async("answer")
|
|
60
|
+
if isinstance(result, CacheHit):
|
|
61
|
+
assert result.value == 42
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Contract Notes
|
|
65
|
+
|
|
66
|
+
`RedisCache` is a backend implementation of the `spakky-cache` application data cache contract. Business code should depend on `ICache[T]` and can switch between `InMemoryCache` and `RedisCache` without changing cache hit/miss handling.
|
|
67
|
+
|
|
68
|
+
Values are serialized with pickle and stored under `SPAKKY_REDIS__KEY_PREFIX`. `clear()` and `clear_async()` delete only keys under that prefix. Redis failures, unexpected response types, and serialization failures are raised as Spakky cache errors instead of being treated as cache misses.
|
|
69
|
+
|
|
70
|
+
This plugin does not add distributed locks, cache stampede protection, tag invalidation, write-through/write-behind policies, or metrics exporters.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT License
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "spakky-redis"
|
|
3
|
+
version = "6.3.1"
|
|
4
|
+
description = "Redis cache backend plugin for Spakky Framework"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Spakky", email = "sejong418@icloud.com" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pydantic-settings>=2.13.1",
|
|
11
|
+
"redis>=7.1.0",
|
|
12
|
+
"spakky-cache>=6.3.1",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.entry-points."spakky.plugins"]
|
|
16
|
+
spakky-redis = "spakky.plugins.redis.main:initialize"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"fakeredis>=2.32.1",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.10.10,<0.11.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[tool.uv.build-backend]
|
|
28
|
+
module-root = "src"
|
|
29
|
+
module-name = "spakky.plugins.redis"
|
|
30
|
+
|
|
31
|
+
[tool.pyrefly]
|
|
32
|
+
python-version = "3.11"
|
|
33
|
+
search_path = ["src", ".", "../../core/spakky/src", "../../core/spakky-cache/src"]
|
|
34
|
+
project_excludes = ["**/__pycache__", "**/*.pyc"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
builtins = ["_"]
|
|
38
|
+
cache-dir = "~/.cache/ruff"
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
pythonpath = ["src", "../../core/spakky/src", "../../core/spakky-cache/src"]
|
|
42
|
+
testpaths = "tests"
|
|
43
|
+
python_files = ["test_*.py"]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
addopts = """
|
|
46
|
+
--cov
|
|
47
|
+
--cov-report=term
|
|
48
|
+
--cov-report=xml
|
|
49
|
+
--no-cov-on-fail
|
|
50
|
+
--strict-markers
|
|
51
|
+
--dist=loadfile
|
|
52
|
+
-p no:warnings
|
|
53
|
+
-n auto
|
|
54
|
+
--spec
|
|
55
|
+
"""
|
|
56
|
+
spec_test_format = "{result} {docstring_summary}"
|
|
57
|
+
|
|
58
|
+
[tool.coverage.run]
|
|
59
|
+
include = ["src/spakky/plugins/redis/**/*.py"]
|
|
60
|
+
branch = true
|
|
61
|
+
|
|
62
|
+
[tool.coverage.report]
|
|
63
|
+
show_missing = true
|
|
64
|
+
precision = 2
|
|
65
|
+
fail_under = 90
|
|
66
|
+
skip_empty = true
|
|
67
|
+
exclude_lines = [
|
|
68
|
+
"pragma: no cover",
|
|
69
|
+
"def __repr__",
|
|
70
|
+
"raise AssertionError",
|
|
71
|
+
"raise NotImplementedError",
|
|
72
|
+
"@(abc\\.)?abstractmethod",
|
|
73
|
+
"@(typing\\.)?overload",
|
|
74
|
+
"\\.\\.\\.",
|
|
75
|
+
"pass",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.uv.sources]
|
|
79
|
+
spakky = { workspace = true }
|
|
80
|
+
spakky-cache = { workspace = true }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Redis cache backend plugin for Spakky Framework."""
|
|
2
|
+
|
|
3
|
+
from spakky.core.application.plugin import Plugin
|
|
4
|
+
|
|
5
|
+
from spakky.plugins.redis.cache import RedisCache
|
|
6
|
+
from spakky.plugins.redis.common.config import RedisCacheConfig
|
|
7
|
+
from spakky.plugins.redis.error import (
|
|
8
|
+
AbstractSpakkyRedisError,
|
|
9
|
+
RedisCacheOperationError,
|
|
10
|
+
RedisCacheSerializationError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
PLUGIN_NAME = Plugin(name="spakky-redis")
|
|
14
|
+
"""Plugin identifier for the Spakky Redis package."""
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AbstractSpakkyRedisError",
|
|
18
|
+
"PLUGIN_NAME",
|
|
19
|
+
"RedisCache",
|
|
20
|
+
"RedisCacheConfig",
|
|
21
|
+
"RedisCacheOperationError",
|
|
22
|
+
"RedisCacheSerializationError",
|
|
23
|
+
]
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Redis implementation of the spakky-cache contract."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import AsyncIterator, Iterator
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
import pickle
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from redis import Redis
|
|
10
|
+
from redis.asyncio import Redis as AsyncRedis
|
|
11
|
+
from redis.exceptions import RedisError
|
|
12
|
+
from typing_extensions import override
|
|
13
|
+
|
|
14
|
+
from spakky.cache import ICache, CacheHit, CacheMiss, CacheResult, CacheTTL
|
|
15
|
+
from spakky.cache.error import InvalidCacheTTLError
|
|
16
|
+
from spakky.core.pod.annotations.pod import Pod
|
|
17
|
+
from spakky.plugins.redis.common.config import RedisCacheConfig
|
|
18
|
+
from spakky.plugins.redis.error import (
|
|
19
|
+
RedisCacheOperationError,
|
|
20
|
+
RedisCacheSerializationError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
RedisKey = str | bytes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ISyncRedisClient(ABC):
|
|
28
|
+
"""Explicit sync Redis boundary used by RedisCache."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def ping(self) -> None: ...
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def get(self, name: str) -> bytes | None: ...
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def set(self, name: str, value: bytes, *, px: int | None = None) -> None: ...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def delete(self, *names: RedisKey) -> int: ...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def scan_iter(self, match: str) -> Iterator[RedisKey]: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class IAsyncRedisClient(ABC):
|
|
47
|
+
"""Explicit async Redis boundary used by RedisCache."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def get(self, name: str) -> bytes | None: ...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def set(self, name: str, value: bytes, *, px: int | None = None) -> None: ...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def delete(self, *names: RedisKey) -> int: ...
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def scan_iter(self, match: str) -> AsyncIterator[RedisKey]: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class IRawSyncRedisClient(ABC):
|
|
63
|
+
"""Explicit raw sync Redis boundary before response narrowing."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def ping(self) -> object: ...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def get(self, name: str) -> object: ...
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def set(self, name: str, value: bytes, *, px: int | None = None) -> object: ...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def delete(self, *names: RedisKey) -> object: ...
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def scan_iter(self, match: str) -> Iterator[RedisKey]: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class IRawAsyncRedisClient(ABC):
|
|
82
|
+
"""Explicit raw async Redis boundary before response narrowing."""
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
async def get(self, name: str) -> object: ...
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
async def set(
|
|
89
|
+
self, name: str, value: bytes, *, px: int | None = None
|
|
90
|
+
) -> object: ...
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
async def delete(self, *names: RedisKey) -> object: ...
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def scan_iter(self, match: str) -> AsyncIterator[RedisKey]: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RedisRawSyncClient(IRawSyncRedisClient):
|
|
100
|
+
"""Raw sync Redis client wrapper for redis-py."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, raw: Redis) -> None:
|
|
103
|
+
self._raw = raw
|
|
104
|
+
|
|
105
|
+
@override
|
|
106
|
+
def ping(self) -> object:
|
|
107
|
+
return self._raw.ping()
|
|
108
|
+
|
|
109
|
+
@override
|
|
110
|
+
def get(self, name: str) -> object:
|
|
111
|
+
return self._raw.get(name)
|
|
112
|
+
|
|
113
|
+
@override
|
|
114
|
+
def set(self, name: str, value: bytes, *, px: int | None = None) -> object:
|
|
115
|
+
return self._raw.set(name, value, px=px)
|
|
116
|
+
|
|
117
|
+
@override
|
|
118
|
+
def delete(self, *names: RedisKey) -> object:
|
|
119
|
+
return self._raw.delete(*names)
|
|
120
|
+
|
|
121
|
+
@override
|
|
122
|
+
def scan_iter(self, match: str) -> Iterator[RedisKey]:
|
|
123
|
+
return self._raw.scan_iter(match=match)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RedisRawAsyncClient(IRawAsyncRedisClient):
|
|
127
|
+
"""Raw async Redis client wrapper for redis-py."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, raw: AsyncRedis) -> None:
|
|
130
|
+
self._raw = raw
|
|
131
|
+
|
|
132
|
+
@override
|
|
133
|
+
async def get(self, name: str) -> object:
|
|
134
|
+
return await self._raw.get(name)
|
|
135
|
+
|
|
136
|
+
@override
|
|
137
|
+
async def set(self, name: str, value: bytes, *, px: int | None = None) -> object:
|
|
138
|
+
return await self._raw.set(name, value, px=px)
|
|
139
|
+
|
|
140
|
+
@override
|
|
141
|
+
async def delete(self, *names: RedisKey) -> object:
|
|
142
|
+
return await self._raw.delete(*names)
|
|
143
|
+
|
|
144
|
+
@override
|
|
145
|
+
def scan_iter(self, match: str) -> AsyncIterator[RedisKey]:
|
|
146
|
+
return self._raw.scan_iter(match=match)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class SyncRedisAdapter(ISyncRedisClient):
|
|
150
|
+
"""Typed boundary over the redis-py sync client."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, raw: IRawSyncRedisClient) -> None:
|
|
153
|
+
self._raw = raw
|
|
154
|
+
|
|
155
|
+
@override
|
|
156
|
+
def ping(self) -> None:
|
|
157
|
+
try:
|
|
158
|
+
self._raw.ping()
|
|
159
|
+
except RedisError as e:
|
|
160
|
+
raise RedisCacheOperationError from e
|
|
161
|
+
|
|
162
|
+
@override
|
|
163
|
+
def get(self, name: str) -> bytes | None:
|
|
164
|
+
try:
|
|
165
|
+
payload = self._raw.get(name)
|
|
166
|
+
except RedisError as e:
|
|
167
|
+
raise RedisCacheOperationError from e
|
|
168
|
+
if payload is None:
|
|
169
|
+
return None
|
|
170
|
+
if isinstance(payload, bytes):
|
|
171
|
+
return payload
|
|
172
|
+
raise RedisCacheOperationError
|
|
173
|
+
|
|
174
|
+
@override
|
|
175
|
+
def set(self, name: str, value: bytes, *, px: int | None = None) -> None:
|
|
176
|
+
try:
|
|
177
|
+
self._raw.set(name, value, px=px)
|
|
178
|
+
except RedisError as e:
|
|
179
|
+
raise RedisCacheOperationError from e
|
|
180
|
+
|
|
181
|
+
@override
|
|
182
|
+
def delete(self, *names: RedisKey) -> int:
|
|
183
|
+
try:
|
|
184
|
+
deleted = self._raw.delete(*names)
|
|
185
|
+
except RedisError as e:
|
|
186
|
+
raise RedisCacheOperationError from e
|
|
187
|
+
if isinstance(deleted, int):
|
|
188
|
+
return deleted
|
|
189
|
+
raise RedisCacheOperationError
|
|
190
|
+
|
|
191
|
+
@override
|
|
192
|
+
def scan_iter(self, match: str) -> Iterator[RedisKey]:
|
|
193
|
+
try:
|
|
194
|
+
return self._raw.scan_iter(match=match)
|
|
195
|
+
except RedisError as e:
|
|
196
|
+
raise RedisCacheOperationError from e
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class AsyncRedisAdapter(IAsyncRedisClient):
|
|
200
|
+
"""Typed boundary over the redis-py async client."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, raw: IRawAsyncRedisClient) -> None:
|
|
203
|
+
self._raw = raw
|
|
204
|
+
|
|
205
|
+
@override
|
|
206
|
+
async def get(self, name: str) -> bytes | None:
|
|
207
|
+
try:
|
|
208
|
+
payload = await self._raw.get(name)
|
|
209
|
+
except RedisError as e:
|
|
210
|
+
raise RedisCacheOperationError from e
|
|
211
|
+
if payload is None:
|
|
212
|
+
return None
|
|
213
|
+
if isinstance(payload, bytes):
|
|
214
|
+
return payload
|
|
215
|
+
raise RedisCacheOperationError
|
|
216
|
+
|
|
217
|
+
@override
|
|
218
|
+
async def set(self, name: str, value: bytes, *, px: int | None = None) -> None:
|
|
219
|
+
try:
|
|
220
|
+
await self._raw.set(name, value, px=px)
|
|
221
|
+
except RedisError as e:
|
|
222
|
+
raise RedisCacheOperationError from e
|
|
223
|
+
|
|
224
|
+
@override
|
|
225
|
+
async def delete(self, *names: RedisKey) -> int:
|
|
226
|
+
try:
|
|
227
|
+
deleted = await self._raw.delete(*names)
|
|
228
|
+
except RedisError as e:
|
|
229
|
+
raise RedisCacheOperationError from e
|
|
230
|
+
if isinstance(deleted, int):
|
|
231
|
+
return deleted
|
|
232
|
+
raise RedisCacheOperationError
|
|
233
|
+
|
|
234
|
+
@override
|
|
235
|
+
def scan_iter(self, match: str) -> AsyncIterator[RedisKey]:
|
|
236
|
+
try:
|
|
237
|
+
return self._raw.scan_iter(match=match)
|
|
238
|
+
except RedisError as e:
|
|
239
|
+
raise RedisCacheOperationError from e
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@Pod()
|
|
243
|
+
class RedisCache(ICache[T], Generic[T]):
|
|
244
|
+
"""Redis-backed cache that stores pickled values under a configured prefix."""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
config: RedisCacheConfig | None = None,
|
|
249
|
+
*,
|
|
250
|
+
client: ISyncRedisClient | None = None,
|
|
251
|
+
async_client: IAsyncRedisClient | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
self._config = config or RedisCacheConfig()
|
|
254
|
+
self._client = client or self._create_client()
|
|
255
|
+
self._async_client = async_client or self._create_async_client()
|
|
256
|
+
self._ping()
|
|
257
|
+
|
|
258
|
+
@override
|
|
259
|
+
def get(self, key: str) -> CacheResult[T]:
|
|
260
|
+
payload = self._client.get(self._redis_key(key))
|
|
261
|
+
if payload is None:
|
|
262
|
+
return CacheMiss()
|
|
263
|
+
return CacheHit(value=self._deserialize(payload))
|
|
264
|
+
|
|
265
|
+
@override
|
|
266
|
+
def set(self, key: str, value: T, *, ttl: CacheTTL = None) -> None:
|
|
267
|
+
payload = self._serialize(value)
|
|
268
|
+
ttl_ms = self._ttl_ms(ttl)
|
|
269
|
+
self._client.set(self._redis_key(key), payload, px=ttl_ms)
|
|
270
|
+
|
|
271
|
+
@override
|
|
272
|
+
def delete(self, key: str) -> bool:
|
|
273
|
+
return self._client.delete(self._redis_key(key)) > 0
|
|
274
|
+
|
|
275
|
+
@override
|
|
276
|
+
def clear(self) -> None:
|
|
277
|
+
keys = list(self._client.scan_iter(match=f"{self._config.key_prefix}*"))
|
|
278
|
+
if keys:
|
|
279
|
+
self._client.delete(*keys)
|
|
280
|
+
|
|
281
|
+
@override
|
|
282
|
+
async def get_async(self, key: str) -> CacheResult[T]:
|
|
283
|
+
payload = await self._async_client.get(self._redis_key(key))
|
|
284
|
+
if payload is None:
|
|
285
|
+
return CacheMiss()
|
|
286
|
+
return CacheHit(value=self._deserialize(payload))
|
|
287
|
+
|
|
288
|
+
@override
|
|
289
|
+
async def set_async(self, key: str, value: T, *, ttl: CacheTTL = None) -> None:
|
|
290
|
+
payload = self._serialize(value)
|
|
291
|
+
ttl_ms = self._ttl_ms(ttl)
|
|
292
|
+
await self._async_client.set(self._redis_key(key), payload, px=ttl_ms)
|
|
293
|
+
|
|
294
|
+
@override
|
|
295
|
+
async def delete_async(self, key: str) -> bool:
|
|
296
|
+
return await self._async_client.delete(self._redis_key(key)) > 0
|
|
297
|
+
|
|
298
|
+
@override
|
|
299
|
+
async def clear_async(self) -> None:
|
|
300
|
+
keys = [
|
|
301
|
+
key
|
|
302
|
+
async for key in self._async_client.scan_iter(
|
|
303
|
+
match=f"{self._config.key_prefix}*"
|
|
304
|
+
)
|
|
305
|
+
]
|
|
306
|
+
if keys:
|
|
307
|
+
await self._async_client.delete(*keys)
|
|
308
|
+
|
|
309
|
+
def _create_client(self) -> ISyncRedisClient:
|
|
310
|
+
return SyncRedisAdapter(
|
|
311
|
+
RedisRawSyncClient(
|
|
312
|
+
Redis.from_url(
|
|
313
|
+
self._config.connection_url,
|
|
314
|
+
username=self._config.username,
|
|
315
|
+
password=self._config.password,
|
|
316
|
+
socket_timeout=self._config.socket_timeout,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def _create_async_client(self) -> IAsyncRedisClient:
|
|
322
|
+
return AsyncRedisAdapter(
|
|
323
|
+
RedisRawAsyncClient(
|
|
324
|
+
AsyncRedis.from_url(
|
|
325
|
+
self._config.connection_url,
|
|
326
|
+
username=self._config.username,
|
|
327
|
+
password=self._config.password,
|
|
328
|
+
socket_timeout=self._config.socket_timeout,
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _ping(self) -> None:
|
|
334
|
+
self._client.ping()
|
|
335
|
+
|
|
336
|
+
def _redis_key(self, key: str) -> str:
|
|
337
|
+
return f"{self._config.key_prefix}{key}"
|
|
338
|
+
|
|
339
|
+
def _ttl_ms(self, ttl: CacheTTL) -> int | None:
|
|
340
|
+
if ttl is None:
|
|
341
|
+
return None
|
|
342
|
+
if isinstance(ttl, timedelta):
|
|
343
|
+
ttl_seconds = ttl.total_seconds()
|
|
344
|
+
else:
|
|
345
|
+
ttl_seconds = float(ttl)
|
|
346
|
+
if ttl_seconds <= 0:
|
|
347
|
+
raise InvalidCacheTTLError()
|
|
348
|
+
return max(1, int(ttl_seconds * 1000))
|
|
349
|
+
|
|
350
|
+
def _serialize(self, value: T) -> bytes:
|
|
351
|
+
try:
|
|
352
|
+
return pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
|
|
353
|
+
except (pickle.PickleError, AttributeError, TypeError) as e:
|
|
354
|
+
raise RedisCacheSerializationError from e
|
|
355
|
+
|
|
356
|
+
def _deserialize(self, payload: bytes) -> T:
|
|
357
|
+
try:
|
|
358
|
+
value = pickle.loads(payload)
|
|
359
|
+
except (
|
|
360
|
+
pickle.PickleError,
|
|
361
|
+
EOFError,
|
|
362
|
+
AttributeError,
|
|
363
|
+
ImportError,
|
|
364
|
+
IndexError,
|
|
365
|
+
) as e:
|
|
366
|
+
raise RedisCacheSerializationError from e
|
|
367
|
+
return value
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Common Redis plugin configuration."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Redis cache backend configuration."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from pydantic import NonNegativeInt, PositiveInt
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
from spakky.core.stereotype.configuration import Configuration
|
|
8
|
+
|
|
9
|
+
SPAKKY_REDIS_CONFIG_ENV_PREFIX: str = "SPAKKY_REDIS__"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@Configuration()
|
|
13
|
+
class RedisCacheConfig(BaseSettings):
|
|
14
|
+
"""Redis cache backend configuration loaded from environment variables."""
|
|
15
|
+
|
|
16
|
+
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
|
17
|
+
env_prefix=SPAKKY_REDIS_CONFIG_ENV_PREFIX,
|
|
18
|
+
env_file_encoding="utf-8",
|
|
19
|
+
env_nested_delimiter="__",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
host: str = "localhost"
|
|
23
|
+
"""Redis server hostname."""
|
|
24
|
+
|
|
25
|
+
port: PositiveInt = 6379
|
|
26
|
+
"""Redis server port."""
|
|
27
|
+
|
|
28
|
+
db: NonNegativeInt = 0
|
|
29
|
+
"""Redis database index."""
|
|
30
|
+
|
|
31
|
+
username: str | None = None
|
|
32
|
+
"""Optional Redis ACL username."""
|
|
33
|
+
|
|
34
|
+
password: str | None = None
|
|
35
|
+
"""Optional Redis password."""
|
|
36
|
+
|
|
37
|
+
use_ssl: bool = False
|
|
38
|
+
"""Use redis+ssl transport when true."""
|
|
39
|
+
|
|
40
|
+
key_prefix: str = "spakky:cache:"
|
|
41
|
+
"""Prefix applied to keys managed by this cache backend."""
|
|
42
|
+
|
|
43
|
+
socket_timeout: float = 5.0
|
|
44
|
+
"""Socket timeout in seconds for Redis operations."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def scheme(self) -> str:
|
|
51
|
+
"""Return the Redis URL scheme for this configuration."""
|
|
52
|
+
return "rediss" if self.use_ssl else "redis"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def connection_url(self) -> str:
|
|
56
|
+
"""Return a Redis connection URL without embedding credentials."""
|
|
57
|
+
return f"{self.scheme}://{self.host}:{self.port}/{self.db}"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Error classes for the spakky-redis package."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
|
|
5
|
+
from spakky.cache.error import AbstractSpakkyCacheError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AbstractSpakkyRedisError(AbstractSpakkyCacheError, ABC):
|
|
9
|
+
"""Base class for Redis cache backend errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RedisCacheOperationError(AbstractSpakkyRedisError):
|
|
13
|
+
"""Raised when a Redis operation fails."""
|
|
14
|
+
|
|
15
|
+
message = "Redis cache operation failed"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RedisCacheSerializationError(AbstractSpakkyRedisError):
|
|
19
|
+
"""Raised when a cache value cannot be serialized or deserialized."""
|
|
20
|
+
|
|
21
|
+
message = "Redis cache serialization failed"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Plugin initialization for Redis cache integration."""
|
|
2
|
+
|
|
3
|
+
from spakky.core.application.application import SpakkyApplication
|
|
4
|
+
|
|
5
|
+
from spakky.plugins.redis.cache import RedisCache
|
|
6
|
+
from spakky.plugins.redis.common.config import RedisCacheConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def initialize(app: SpakkyApplication) -> None:
|
|
10
|
+
"""Register Redis cache configuration and backend pods."""
|
|
11
|
+
app.add(RedisCacheConfig)
|
|
12
|
+
app.add(RedisCache)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|