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.
@@ -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)