cledar-sdk 1.2.1__py3-none-any.whl → 1.3.0__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.
- {cledar_sdk-1.2.1.dist-info → cledar_sdk-1.3.0.dist-info}/METADATA +1 -1
- {cledar_sdk-1.2.1.dist-info → cledar_sdk-1.3.0.dist-info}/RECORD +10 -7
- redis_service/README.md +156 -16
- redis_service/__init__.py +15 -0
- redis_service/async_example.py +111 -0
- redis_service/redis.py +247 -0
- redis_service/tests/test_async_integration_redis.py +162 -0
- redis_service/tests/test_async_redis_service.py +384 -0
- {cledar_sdk-1.2.1.dist-info → cledar_sdk-1.3.0.dist-info}/WHEEL +0 -0
- {cledar_sdk-1.2.1.dist-info → cledar_sdk-1.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -52,14 +52,17 @@ nonce_service/__init__.py,sha256=Rh6JWML_ncfb_t_mVl7PIKOXpELRdfunMsFWAtbt5EE,68
|
|
|
52
52
|
nonce_service/nonce_service.py,sha256=6RhA2eEztH_pNdjkBI5eqmRh14I96NjgVYy2v_z7vdM,1100
|
|
53
53
|
nonce_service/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
54
54
|
nonce_service/tests/test_nonce_service.py,sha256=sWzCBXghZNDOnSo8NQB6tyJJ95g2tZGRPlThFqD2P7g,4047
|
|
55
|
-
redis_service/README.md,sha256=
|
|
56
|
-
redis_service/__init__.py,sha256=
|
|
55
|
+
redis_service/README.md,sha256=rsIieAu4TwjoeIbHG30x3t3TyR9P9Ulcy69gdfG_jRg,14632
|
|
56
|
+
redis_service/__init__.py,sha256=SgTAyteZ3pPV8R608-EQ84RmytUfSQaPGl0UJxLnGL0,250
|
|
57
|
+
redis_service/async_example.py,sha256=m8dkSspJK8yti9U_Q3_Sc9XW1aq3RYL4EzBdLx5_u84,2949
|
|
57
58
|
redis_service/example.py,sha256=OApl0JU59viBUZORLxlYBhVMdCc5QvOBdOjscxbm_aQ,1091
|
|
58
59
|
redis_service/exceptions.py,sha256=vvD7SO0xHutSLvUf0ttMo7C6OeaVi8f3bxMservsSVI,737
|
|
59
60
|
redis_service/logger.py,sha256=OBOTx6zk_6wkpB2N_FRV7gXR3xy4dpy4iX2B0oFfZ90,60
|
|
60
61
|
redis_service/model.py,sha256=ykW_KHygNHhfHPvP2RyJj0g_WENXxJEP2HvGy7Yb4uo,174
|
|
61
|
-
redis_service/redis.py,sha256=
|
|
62
|
+
redis_service/redis.py,sha256=DuWaXMU5RhAW2Rf9T-Hg_1FIrQqkPin1vTXQMJJ6Sno,19817
|
|
62
63
|
redis_service/redis_config_store.py,sha256=EFhyvg_Eklrh2tc5dtFpe6nnjMDkTqYT_egclhf0KaI,8919
|
|
64
|
+
redis_service/tests/test_async_integration_redis.py,sha256=I-rWvOm9Cnpl8AZcrPb58tTbkq_YKh3WR2bwXB-EJRM,5005
|
|
65
|
+
redis_service/tests/test_async_redis_service.py,sha256=DgzBZscXu_FiV6QIZT2AHQ0rk_Zwzu2HmsifeDhArRU,12501
|
|
63
66
|
redis_service/tests/test_integration_redis.py,sha256=O3z5EgkeB6Sd9C1S3rRMW916bybDCAHb-DgTFZGLTqI,3345
|
|
64
67
|
redis_service/tests/test_redis_service.py,sha256=n3FIYBk9SGzpDkRWnPj-Mi3NKYtDEpbwgjpjb_JwjF4,10252
|
|
65
68
|
storage_service/README.md,sha256=Rkew1joVijHVEfyXS9C7dDw55rQ4HYRbHkRYlZiREak,15655
|
|
@@ -74,7 +77,7 @@ storage_service/tests/test_integration_filesystem.py,sha256=-H3Skc_geYIjXW1si-8u
|
|
|
74
77
|
storage_service/tests/test_integration_s3.py,sha256=Ivg_52LXibqVGMS-53z4zda_Yh4u6FO8WplUUu5WWBc,13614
|
|
75
78
|
storage_service/tests/test_local.py,sha256=3CgtxQ_lBBaPR4t9Ip0i7T98scrTOCdkmHYMVansNCc,11256
|
|
76
79
|
storage_service/tests/test_s3.py,sha256=zAppsvVCeLx_NN1tQfqHo57mONEjeFDmiwyecrS3ZgQ,16355
|
|
77
|
-
cledar_sdk-1.
|
|
78
|
-
cledar_sdk-1.
|
|
79
|
-
cledar_sdk-1.
|
|
80
|
-
cledar_sdk-1.
|
|
80
|
+
cledar_sdk-1.3.0.dist-info/METADATA,sha256=x9Pa7dwAJdLAW5A1eY8qQD-DTvMkUp5nrkWpCXvD3nA,6752
|
|
81
|
+
cledar_sdk-1.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
82
|
+
cledar_sdk-1.3.0.dist-info/licenses/LICENSE,sha256=Pz2eACSxkhsGfW9_iN60pgy-enjnbGTj8df8O3ebnQQ,16726
|
|
83
|
+
cledar_sdk-1.3.0.dist-info/RECORD,,
|
redis_service/README.md
CHANGED
|
@@ -7,6 +7,7 @@ The `redis_service` package provides a typed, high-level interface over Redis fo
|
|
|
7
7
|
### Key Features
|
|
8
8
|
|
|
9
9
|
- **Typed API with Pydantic**: Validate JSON payloads into Pydantic models on read
|
|
10
|
+
- **Async/Sync Support**: Both `AsyncRedisService` (async/await) and `RedisService` (synchronous) available
|
|
10
11
|
- **Safe Serialization**: Custom JSON encoder for `Enum` (to lowercase names) and `datetime` (ISO 8601)
|
|
11
12
|
- **Ergonomic Helpers**: `get`, `get_raw`, `set`, `list_keys`, `mget`, `delete`
|
|
12
13
|
- **Bulk Reads**: `mget` returns a list with typed results, `None`, or `FailedValue` for per-key errors
|
|
@@ -21,6 +22,7 @@ The `redis_service` package provides a typed, high-level interface over Redis fo
|
|
|
21
22
|
- Reading and writing typed configuration objects
|
|
22
23
|
- Bulk retrieval of many keys while tolerating per-key failures
|
|
23
24
|
- Observing and reacting to configuration changes in near real-time
|
|
25
|
+
- Asynchronous I/O for high-performance applications (FastAPI, aiohttp, etc.)
|
|
24
26
|
|
|
25
27
|
## Installation
|
|
26
28
|
|
|
@@ -34,7 +36,9 @@ uv sync --all-groups
|
|
|
34
36
|
pip install -e .
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
## Usage
|
|
39
|
+
## Usage Examples
|
|
40
|
+
|
|
41
|
+
### Synchronous Usage
|
|
38
42
|
|
|
39
43
|
```python
|
|
40
44
|
from pydantic import BaseModel
|
|
@@ -78,6 +82,122 @@ bulk = service.mget(keys, UserModel)
|
|
|
78
82
|
service.delete("greeting")
|
|
79
83
|
```
|
|
80
84
|
|
|
85
|
+
### Asynchronous Usage
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import asyncio
|
|
89
|
+
from pydantic import BaseModel
|
|
90
|
+
from redis_service.redis import AsyncRedisService, RedisServiceConfig
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class UserModel(BaseModel):
|
|
94
|
+
user_id: int
|
|
95
|
+
name: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def main():
|
|
99
|
+
# Configure and create async service
|
|
100
|
+
config = RedisServiceConfig(
|
|
101
|
+
redis_host="localhost",
|
|
102
|
+
redis_port=6379,
|
|
103
|
+
redis_db=0,
|
|
104
|
+
)
|
|
105
|
+
service = AsyncRedisService(config)
|
|
106
|
+
await service.connect()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
# Health check
|
|
110
|
+
assert await service.is_alive() is True
|
|
111
|
+
|
|
112
|
+
# Write a typed value (automatically serialized to JSON)
|
|
113
|
+
user = UserModel(user_id=1, name="Alice")
|
|
114
|
+
await service.set("user:1", user)
|
|
115
|
+
|
|
116
|
+
# Read and validate back into the model
|
|
117
|
+
loaded = await service.get("user:1", UserModel)
|
|
118
|
+
print(loaded) # UserModel(user_id=1, name='Alice')
|
|
119
|
+
|
|
120
|
+
# Raw access (no validation/decoding beyond Redis decode_responses)
|
|
121
|
+
await service.set("greeting", "hello")
|
|
122
|
+
print(await service.get_raw("greeting")) # "hello"
|
|
123
|
+
|
|
124
|
+
# List keys by pattern and bulk-fetch
|
|
125
|
+
keys = await service.list_keys("user:*")
|
|
126
|
+
bulk = await service.mget(keys, UserModel)
|
|
127
|
+
# bulk is a list of UserModel | None | FailedValue
|
|
128
|
+
|
|
129
|
+
# Delete
|
|
130
|
+
await service.delete("greeting")
|
|
131
|
+
|
|
132
|
+
finally:
|
|
133
|
+
# Always close the connection
|
|
134
|
+
await service.close()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
asyncio.run(main())
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### FastAPI Integration Example
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from contextlib import asynccontextmanager
|
|
145
|
+
from fastapi import FastAPI, Depends
|
|
146
|
+
from pydantic import BaseModel
|
|
147
|
+
from redis_service.redis import AsyncRedisService, RedisServiceConfig
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class UserModel(BaseModel):
|
|
151
|
+
user_id: int
|
|
152
|
+
name: str
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Global service instance
|
|
156
|
+
redis_service: AsyncRedisService | None = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@asynccontextmanager
|
|
160
|
+
async def lifespan(app: FastAPI):
|
|
161
|
+
# Startup: initialize Redis service
|
|
162
|
+
global redis_service
|
|
163
|
+
config = RedisServiceConfig(
|
|
164
|
+
redis_host="localhost",
|
|
165
|
+
redis_port=6379,
|
|
166
|
+
redis_db=0,
|
|
167
|
+
)
|
|
168
|
+
redis_service = AsyncRedisService(config)
|
|
169
|
+
await redis_service.connect()
|
|
170
|
+
|
|
171
|
+
yield
|
|
172
|
+
|
|
173
|
+
# Shutdown: close Redis connection
|
|
174
|
+
if redis_service:
|
|
175
|
+
await redis_service.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
app = FastAPI(lifespan=lifespan)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_redis() -> AsyncRedisService:
|
|
182
|
+
if redis_service is None:
|
|
183
|
+
raise RuntimeError("Redis service not initialized")
|
|
184
|
+
return redis_service
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.get("/users/{user_id}")
|
|
188
|
+
async def get_user(user_id: int, redis: AsyncRedisService = Depends(get_redis)):
|
|
189
|
+
user = await redis.get(f"user:{user_id}", UserModel)
|
|
190
|
+
if user is None:
|
|
191
|
+
return {"error": "User not found"}
|
|
192
|
+
return user
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.post("/users")
|
|
196
|
+
async def create_user(user: UserModel, redis: AsyncRedisService = Depends(get_redis)):
|
|
197
|
+
await redis.set(f"user:{user.user_id}", user)
|
|
198
|
+
return {"status": "created", "user": user}
|
|
199
|
+
```
|
|
200
|
+
|
|
81
201
|
## Development
|
|
82
202
|
|
|
83
203
|
### Project Structure
|
|
@@ -85,16 +205,18 @@ service.delete("greeting")
|
|
|
85
205
|
```
|
|
86
206
|
redis_service/
|
|
87
207
|
├── __init__.py
|
|
88
|
-
├── exceptions.py
|
|
89
|
-
├── logger.py
|
|
90
|
-
├── model.py
|
|
91
|
-
├── redis.py
|
|
92
|
-
├── redis_config_store.py
|
|
93
|
-
├── example.py
|
|
208
|
+
├── exceptions.py # Custom exceptions
|
|
209
|
+
├── logger.py # Module logger
|
|
210
|
+
├── model.py # Base config type for RedisConfigStore
|
|
211
|
+
├── redis.py # RedisService and AsyncRedisService
|
|
212
|
+
├── redis_config_store.py # Config store with caching and watchers
|
|
213
|
+
├── example.py # Small example of using RedisConfigStore
|
|
94
214
|
├── tests/
|
|
95
|
-
│ ├── test_redis_service.py
|
|
96
|
-
│
|
|
97
|
-
|
|
215
|
+
│ ├── test_redis_service.py # Sync unit tests (mocked Redis)
|
|
216
|
+
│ ├── test_async_redis_service.py # Async unit tests (mocked Redis)
|
|
217
|
+
│ ├── test_integration_redis.py # Sync integration tests with testcontainers
|
|
218
|
+
│ └── test_async_integration_redis.py # Async integration tests with testcontainers
|
|
219
|
+
└── README.md # This file
|
|
98
220
|
```
|
|
99
221
|
|
|
100
222
|
## Running Linters
|
|
@@ -158,9 +280,9 @@ PYTHONPATH=$PWD uv run pytest redis_service/tests/test_redis_service.py::test_se
|
|
|
158
280
|
|
|
159
281
|
### Unit Test Details
|
|
160
282
|
|
|
161
|
-
- **Test Framework**: pytest
|
|
162
|
-
- **Mocking**: unittest.mock
|
|
163
|
-
- **Test Count**:
|
|
283
|
+
- **Test Framework**: pytest, pytest-asyncio
|
|
284
|
+
- **Mocking**: unittest.mock (sync), AsyncMock (async)
|
|
285
|
+
- **Test Count**: 60 unit tests (30 sync + 30 async)
|
|
164
286
|
|
|
165
287
|
## Running Integration Tests
|
|
166
288
|
|
|
@@ -183,10 +305,10 @@ PYTHONPATH=$PWD uv run pytest redis_service/tests/test_integration_redis.py -v
|
|
|
183
305
|
|
|
184
306
|
### Integration Test Details
|
|
185
307
|
|
|
186
|
-
- **Test Framework**: pytest + testcontainers
|
|
308
|
+
- **Test Framework**: pytest, pytest-asyncio + testcontainers
|
|
187
309
|
- **Container**: Redis
|
|
188
310
|
- **Image**: `redis:7.2-alpine`
|
|
189
|
-
- **Test Count**:
|
|
311
|
+
- **Test Count**: 17 integration tests (8 sync + 9 async)
|
|
190
312
|
|
|
191
313
|
### Run All Tests (Unit + Integration)
|
|
192
314
|
|
|
@@ -284,7 +406,7 @@ class RedisServiceConfig:
|
|
|
284
406
|
redis_password: str | None = None
|
|
285
407
|
```
|
|
286
408
|
|
|
287
|
-
### RedisService
|
|
409
|
+
### RedisService (Synchronous)
|
|
288
410
|
|
|
289
411
|
High-level service over `redis.Redis` with JSON handling and typed reads.
|
|
290
412
|
|
|
@@ -298,6 +420,24 @@ High-level service over `redis.Redis` with JSON handling and typed reads.
|
|
|
298
420
|
- `mget(keys: list[str], model: type[T]) -> list[T | None | FailedValue]` — Bulk read with per-key error details
|
|
299
421
|
- `delete(key: str) -> bool` — Delete a key; returns True if a key was removed
|
|
300
422
|
|
|
423
|
+
### AsyncRedisService (Asynchronous)
|
|
424
|
+
|
|
425
|
+
High-level async service over `redis.asyncio.Redis` with JSON handling and typed reads.
|
|
426
|
+
|
|
427
|
+
#### Methods
|
|
428
|
+
|
|
429
|
+
All methods are async (use `await`):
|
|
430
|
+
|
|
431
|
+
- `connect() -> None` — Establish connection to Redis (must be called before using other methods)
|
|
432
|
+
- `close() -> None` — Close the Redis connection
|
|
433
|
+
- `is_alive() -> bool` — Ping Redis to check connectivity
|
|
434
|
+
- `set(key: str, value: Any) -> bool` — Serialize and store a value; supports dict/list, Pydantic models, primitives
|
|
435
|
+
- `get(key: str, model: type[T]) -> T | None` — Read and validate JSON into the given Pydantic model
|
|
436
|
+
- `get_raw(key: str) -> Any | None` — Read raw value (usually string) without validation
|
|
437
|
+
- `list_keys(pattern: str) -> list[str]` — List keys matching a glob-like pattern
|
|
438
|
+
- `mget(keys: list[str], model: type[T]) -> list[T | None | FailedValue]` — Bulk read with per-key error details
|
|
439
|
+
- `delete(key: str) -> bool` — Delete a key; returns True if a key was removed
|
|
440
|
+
|
|
301
441
|
#### Exceptions
|
|
302
442
|
|
|
303
443
|
- `RedisConnectionError` — Connection/transport errors
|
redis_service/__init__.py
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example usage of AsyncRedisService with async/await.
|
|
3
|
+
|
|
4
|
+
This example demonstrates:
|
|
5
|
+
- Connecting to Redis asynchronously
|
|
6
|
+
- Setting and getting typed values
|
|
7
|
+
- Concurrent operations
|
|
8
|
+
- Proper connection lifecycle management
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from redis_service.redis import AsyncRedisService, RedisServiceConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserModel(BaseModel):
|
|
19
|
+
user_id: int
|
|
20
|
+
name: str
|
|
21
|
+
email: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def basic_usage_example() -> None:
|
|
25
|
+
"""Basic async Redis operations."""
|
|
26
|
+
print("=== Basic Async Usage ===")
|
|
27
|
+
|
|
28
|
+
# Configure service
|
|
29
|
+
config = RedisServiceConfig(
|
|
30
|
+
redis_host="localhost",
|
|
31
|
+
redis_port=6379,
|
|
32
|
+
redis_db=0,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Create and connect
|
|
36
|
+
service = AsyncRedisService(config)
|
|
37
|
+
await service.connect()
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
# Health check
|
|
41
|
+
is_alive = await service.is_alive()
|
|
42
|
+
print(f"Redis is alive: {is_alive}")
|
|
43
|
+
|
|
44
|
+
# Store typed data
|
|
45
|
+
user = UserModel(user_id=1, name="Alice", email="alice@example.com")
|
|
46
|
+
await service.set("user:1", user)
|
|
47
|
+
print(f"Stored user: {user}")
|
|
48
|
+
|
|
49
|
+
# Retrieve and validate
|
|
50
|
+
retrieved = await service.get("user:1", UserModel)
|
|
51
|
+
print(f"Retrieved user: {retrieved}")
|
|
52
|
+
|
|
53
|
+
# Store raw string
|
|
54
|
+
await service.set("greeting", "Hello, async world!")
|
|
55
|
+
greeting = await service.get_raw("greeting")
|
|
56
|
+
print(f"Greeting: {greeting}")
|
|
57
|
+
|
|
58
|
+
finally:
|
|
59
|
+
# Always close connection
|
|
60
|
+
await service.close()
|
|
61
|
+
print("Connection closed")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def concurrent_operations_example() -> None:
|
|
65
|
+
"""Demonstrate concurrent async operations."""
|
|
66
|
+
print("\n=== Concurrent Operations ===")
|
|
67
|
+
|
|
68
|
+
config = RedisServiceConfig(
|
|
69
|
+
redis_host="localhost",
|
|
70
|
+
redis_port=6379,
|
|
71
|
+
redis_db=0,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
service = AsyncRedisService(config)
|
|
75
|
+
await service.connect()
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
# Create multiple users concurrently
|
|
79
|
+
users = [
|
|
80
|
+
UserModel(user_id=i, name=f"User{i}", email=f"user{i}@example.com")
|
|
81
|
+
for i in range(1, 11)
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# Store all users concurrently
|
|
85
|
+
set_tasks = [service.set(f"user:{u.user_id}", u) for u in users]
|
|
86
|
+
results = await asyncio.gather(*set_tasks)
|
|
87
|
+
print(f"Stored {sum(results)} users concurrently")
|
|
88
|
+
|
|
89
|
+
# Retrieve all users concurrently
|
|
90
|
+
keys = [f"user:{i}" for i in range(1, 11)]
|
|
91
|
+
retrieved = await service.mget(keys, UserModel)
|
|
92
|
+
user_count = len([r for r in retrieved if isinstance(r, UserModel)])
|
|
93
|
+
print(f"Retrieved {user_count} users")
|
|
94
|
+
|
|
95
|
+
# Clean up concurrently
|
|
96
|
+
delete_tasks = [service.delete(key) for key in keys]
|
|
97
|
+
await asyncio.gather(*delete_tasks)
|
|
98
|
+
print("Cleaned up all users")
|
|
99
|
+
|
|
100
|
+
finally:
|
|
101
|
+
await service.close()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def main() -> None:
|
|
105
|
+
"""Run all examples."""
|
|
106
|
+
await basic_usage_example()
|
|
107
|
+
await concurrent_operations_example()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
asyncio.run(main())
|
redis_service/redis.py
CHANGED
|
@@ -6,6 +6,7 @@ from enum import Enum
|
|
|
6
6
|
from typing import Any, TypeVar, cast
|
|
7
7
|
|
|
8
8
|
import redis
|
|
9
|
+
import redis.asyncio as aioredis
|
|
9
10
|
from pydantic import BaseModel, ValidationError
|
|
10
11
|
|
|
11
12
|
from .exceptions import (
|
|
@@ -276,3 +277,249 @@ class RedisService:
|
|
|
276
277
|
except redis.RedisError as exc:
|
|
277
278
|
logger.exception("Error deleting Redis key.", extra={"key": key})
|
|
278
279
|
raise RedisOperationError(f"Failed to delete key '{key}'") from exc
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class AsyncRedisService:
|
|
283
|
+
"""Asynchronous Redis service with async/await support."""
|
|
284
|
+
|
|
285
|
+
def __init__(self, config: RedisServiceConfig):
|
|
286
|
+
self.config = config
|
|
287
|
+
self._client: aioredis.Redis
|
|
288
|
+
|
|
289
|
+
async def connect(self) -> None:
|
|
290
|
+
"""Establish connection to Redis asynchronously."""
|
|
291
|
+
try:
|
|
292
|
+
self._client = aioredis.Redis(
|
|
293
|
+
host=self.config.redis_host,
|
|
294
|
+
port=self.config.redis_port,
|
|
295
|
+
db=self.config.redis_db,
|
|
296
|
+
password=self.config.redis_password,
|
|
297
|
+
decode_responses=True,
|
|
298
|
+
)
|
|
299
|
+
logger.info(
|
|
300
|
+
"Async Redis client initialized.",
|
|
301
|
+
extra={
|
|
302
|
+
"host": self.config.redis_host,
|
|
303
|
+
"port": self.config.redis_port,
|
|
304
|
+
"db": self.config.redis_db,
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
await self._client.ping()
|
|
308
|
+
logger.info(
|
|
309
|
+
"Async Redis client pinged successfully.",
|
|
310
|
+
extra={"host": self.config.redis_host},
|
|
311
|
+
)
|
|
312
|
+
except aioredis.ConnectionError as exc:
|
|
313
|
+
logger.exception("Failed to initialize async Redis client.")
|
|
314
|
+
raise RedisConnectionError("Could not initialize Redis client") from exc
|
|
315
|
+
|
|
316
|
+
async def close(self) -> None:
|
|
317
|
+
"""Close the Redis connection."""
|
|
318
|
+
await self._client.aclose()
|
|
319
|
+
logger.info("Async Redis client closed.")
|
|
320
|
+
|
|
321
|
+
async def is_alive(self) -> bool:
|
|
322
|
+
"""Check if Redis connection is alive."""
|
|
323
|
+
try:
|
|
324
|
+
return bool(await self._client.ping())
|
|
325
|
+
except aioredis.ConnectionError:
|
|
326
|
+
logger.exception(
|
|
327
|
+
"Redis connection error during health check. Can't ping Redis host %s",
|
|
328
|
+
self.config.redis_host,
|
|
329
|
+
)
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
def _prepare_for_serialization(self, value: Any) -> Any:
|
|
333
|
+
"""
|
|
334
|
+
Recursively process data structures, converting BaseModel instances to
|
|
335
|
+
serializable dicts.
|
|
336
|
+
"""
|
|
337
|
+
if isinstance(value, BaseModel):
|
|
338
|
+
return value.model_dump()
|
|
339
|
+
if isinstance(value, list):
|
|
340
|
+
return [self._prepare_for_serialization(item) for item in value]
|
|
341
|
+
if isinstance(value, dict):
|
|
342
|
+
return {k: self._prepare_for_serialization(v) for k, v in value.items()}
|
|
343
|
+
return value
|
|
344
|
+
|
|
345
|
+
async def set(self, key: str, value: Any) -> bool:
|
|
346
|
+
"""Set a key-value pair in Redis."""
|
|
347
|
+
if not isinstance(key, str):
|
|
348
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
349
|
+
if value is None:
|
|
350
|
+
logger.debug("Value is none", extra={"key": key})
|
|
351
|
+
try:
|
|
352
|
+
processed_value = self._prepare_for_serialization(value)
|
|
353
|
+
if isinstance(processed_value, (dict, list)):
|
|
354
|
+
try:
|
|
355
|
+
final_value = json.dumps(processed_value, cls=CustomEncoder)
|
|
356
|
+
|
|
357
|
+
except (TypeError, ValueError) as exc:
|
|
358
|
+
logger.exception(
|
|
359
|
+
"Serialization error before setting Redis key.",
|
|
360
|
+
extra={"key": key},
|
|
361
|
+
)
|
|
362
|
+
raise RedisSerializationError(
|
|
363
|
+
"Failed to serialize value for Redis"
|
|
364
|
+
) from exc
|
|
365
|
+
|
|
366
|
+
else:
|
|
367
|
+
final_value = processed_value
|
|
368
|
+
return bool(await self._client.set(key, final_value))
|
|
369
|
+
|
|
370
|
+
except aioredis.ConnectionError as exc:
|
|
371
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
372
|
+
raise RedisConnectionError(
|
|
373
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
374
|
+
) from exc
|
|
375
|
+
|
|
376
|
+
except aioredis.RedisError as exc:
|
|
377
|
+
logger.exception("Error setting Redis key.", extra={"key": key})
|
|
378
|
+
raise RedisOperationError(f"Failed to set key '{key}'") from exc
|
|
379
|
+
|
|
380
|
+
async def get(self, key: str, model: type[T]) -> T | None:
|
|
381
|
+
"""Get a value from Redis and validate it against a Pydantic model."""
|
|
382
|
+
if not isinstance(key, str):
|
|
383
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
value = await self._client.get(key)
|
|
387
|
+
if value is None:
|
|
388
|
+
logger.debug("Value is none", extra={"key": key})
|
|
389
|
+
return None
|
|
390
|
+
try:
|
|
391
|
+
return model.model_validate(json.loads(str(value)))
|
|
392
|
+
|
|
393
|
+
except json.JSONDecodeError as exc:
|
|
394
|
+
logger.exception("JSON Decode error.", extra={"key": key})
|
|
395
|
+
raise RedisDeserializationError(
|
|
396
|
+
f"Failed to decode JSON for key '{key}'"
|
|
397
|
+
) from exc
|
|
398
|
+
|
|
399
|
+
except ValidationError as exc:
|
|
400
|
+
logger.exception(
|
|
401
|
+
"Validation error.", extra={"key": key, "model": model}
|
|
402
|
+
)
|
|
403
|
+
raise RedisDeserializationError(
|
|
404
|
+
f"Validation failed for key '{key}' and model '{model.__name__}'"
|
|
405
|
+
) from exc
|
|
406
|
+
|
|
407
|
+
except aioredis.ConnectionError as exc:
|
|
408
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
409
|
+
raise RedisConnectionError(
|
|
410
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
411
|
+
) from exc
|
|
412
|
+
|
|
413
|
+
except aioredis.RedisError as exc:
|
|
414
|
+
logger.exception("Error getting Redis key.", extra={"key": key})
|
|
415
|
+
raise RedisOperationError(f"Failed to get key '{key}'") from exc
|
|
416
|
+
|
|
417
|
+
async def get_raw(self, key: str) -> Any | None:
|
|
418
|
+
"""Get a raw value from Redis without deserialization."""
|
|
419
|
+
if not isinstance(key, str):
|
|
420
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
value = await self._client.get(key)
|
|
424
|
+
if value is None:
|
|
425
|
+
logger.debug("Value is none", extra={"key": key})
|
|
426
|
+
return value
|
|
427
|
+
|
|
428
|
+
except aioredis.ConnectionError as exc:
|
|
429
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
430
|
+
raise RedisConnectionError(
|
|
431
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
432
|
+
) from exc
|
|
433
|
+
|
|
434
|
+
except aioredis.RedisError as exc:
|
|
435
|
+
logger.exception("Error getting Redis key.", extra={"key": key})
|
|
436
|
+
raise RedisOperationError(f"Failed to get key '{key}'") from exc
|
|
437
|
+
|
|
438
|
+
async def list_keys(self, pattern: str) -> list[str]:
|
|
439
|
+
"""List keys matching a pattern."""
|
|
440
|
+
if not isinstance(pattern, str):
|
|
441
|
+
raise ValueError(f"Pattern must be a string, got {type(pattern)}")
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
keys_result = await self._client.keys(pattern)
|
|
445
|
+
return cast(list[str], keys_result)
|
|
446
|
+
|
|
447
|
+
except aioredis.ConnectionError as exc:
|
|
448
|
+
logger.exception("Redis connection error.", extra={"pattern": pattern})
|
|
449
|
+
raise RedisConnectionError(
|
|
450
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
451
|
+
) from exc
|
|
452
|
+
|
|
453
|
+
except aioredis.RedisError as exc:
|
|
454
|
+
logger.exception("Error listing Redis keys.", extra={"pattern": pattern})
|
|
455
|
+
raise RedisOperationError(
|
|
456
|
+
f"Failed to list keys for pattern '{pattern}'"
|
|
457
|
+
) from exc
|
|
458
|
+
|
|
459
|
+
async def mget(
|
|
460
|
+
self, keys: list[str], model: type[T]
|
|
461
|
+
) -> list[T | None | FailedValue]:
|
|
462
|
+
"""Get multiple values from Redis."""
|
|
463
|
+
if not isinstance(keys, list):
|
|
464
|
+
raise ValueError(f"Keys must be a list, got {type(keys)}")
|
|
465
|
+
|
|
466
|
+
if not keys:
|
|
467
|
+
return []
|
|
468
|
+
|
|
469
|
+
try:
|
|
470
|
+
values = cast(list[Any], await self._client.mget(keys))
|
|
471
|
+
results: list[T | None | FailedValue] = []
|
|
472
|
+
|
|
473
|
+
for value, key in zip(values, keys, strict=False):
|
|
474
|
+
if value is None:
|
|
475
|
+
results.append(None)
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
validated_data = model.model_validate(json.loads(str(value)))
|
|
480
|
+
results.append(validated_data)
|
|
481
|
+
|
|
482
|
+
except json.JSONDecodeError as exc:
|
|
483
|
+
logger.exception("JSON Decode error.", extra={"key": key})
|
|
484
|
+
results.append(FailedValue(key=key, error=exc))
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
except ValidationError as exc:
|
|
488
|
+
logger.exception(
|
|
489
|
+
"Validation error.",
|
|
490
|
+
extra={"key": key, "model": model.__name__},
|
|
491
|
+
)
|
|
492
|
+
results.append(FailedValue(key=key, error=exc))
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
return results
|
|
496
|
+
|
|
497
|
+
except aioredis.ConnectionError as exc:
|
|
498
|
+
logger.exception("Redis connection error.", extra={"keys": keys})
|
|
499
|
+
raise RedisConnectionError(
|
|
500
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
501
|
+
) from exc
|
|
502
|
+
|
|
503
|
+
except aioredis.RedisError as exc:
|
|
504
|
+
logger.exception("Error getting multiple Redis keys.")
|
|
505
|
+
raise RedisOperationError("Failed to mget keys") from exc
|
|
506
|
+
|
|
507
|
+
async def delete(self, key: str) -> bool:
|
|
508
|
+
"""Delete a key from Redis."""
|
|
509
|
+
if not isinstance(key, str):
|
|
510
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
result = await self._client.delete(key)
|
|
514
|
+
logger.info("Key deleted successfully", extra={"key": key})
|
|
515
|
+
return bool(result)
|
|
516
|
+
|
|
517
|
+
except aioredis.ConnectionError as exc:
|
|
518
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
519
|
+
raise RedisConnectionError(
|
|
520
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
521
|
+
) from exc
|
|
522
|
+
|
|
523
|
+
except aioredis.RedisError as exc:
|
|
524
|
+
logger.exception("Error deleting Redis key.", extra={"key": key})
|
|
525
|
+
raise RedisOperationError(f"Failed to delete key '{key}'") from exc
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# mypy: disable-error-code=no-untyped-def
|
|
2
|
+
import json
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import pytest_asyncio
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from testcontainers.redis import RedisContainer
|
|
11
|
+
|
|
12
|
+
from redis_service.redis import (
|
|
13
|
+
AsyncRedisService,
|
|
14
|
+
FailedValue,
|
|
15
|
+
RedisServiceConfig,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UserModel(BaseModel):
|
|
20
|
+
user_id: int
|
|
21
|
+
name: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Color(Enum):
|
|
25
|
+
RED = 1
|
|
26
|
+
BLUE = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(scope="module")
|
|
30
|
+
def redis_container():
|
|
31
|
+
"""Start a Redis container for testing."""
|
|
32
|
+
with RedisContainer("redis:7.2-alpine") as redis_db:
|
|
33
|
+
yield redis_db
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest_asyncio.fixture(scope="function")
|
|
37
|
+
async def async_redis_service(
|
|
38
|
+
redis_container: RedisContainer,
|
|
39
|
+
) -> AsyncGenerator[AsyncRedisService, None]:
|
|
40
|
+
host = redis_container.get_container_host_ip()
|
|
41
|
+
port = int(redis_container.get_exposed_port(6379))
|
|
42
|
+
|
|
43
|
+
config = RedisServiceConfig(redis_host=host, redis_port=port, redis_db=0)
|
|
44
|
+
service = AsyncRedisService(config)
|
|
45
|
+
await service.connect()
|
|
46
|
+
yield service
|
|
47
|
+
await service.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.asyncio
|
|
51
|
+
async def test_is_alive(async_redis_service: AsyncRedisService) -> None:
|
|
52
|
+
assert await async_redis_service.is_alive() is True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_set_and_get_pydantic_model(
|
|
57
|
+
async_redis_service: AsyncRedisService,
|
|
58
|
+
) -> None:
|
|
59
|
+
key = "async:user:1"
|
|
60
|
+
model = UserModel(user_id=1, name="Alice")
|
|
61
|
+
assert await async_redis_service.set(key, model) is True
|
|
62
|
+
got = await async_redis_service.get(key, UserModel)
|
|
63
|
+
assert isinstance(got, UserModel)
|
|
64
|
+
assert got == model
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_set_plain_string_and_get_raw(
|
|
69
|
+
async_redis_service: AsyncRedisService,
|
|
70
|
+
) -> None:
|
|
71
|
+
key = "async:greeting"
|
|
72
|
+
assert await async_redis_service.set(key, "hello") is True
|
|
73
|
+
assert await async_redis_service.get_raw(key) == "hello"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_set_with_enum_and_datetime_uses_custom_encoder(
|
|
78
|
+
async_redis_service: AsyncRedisService,
|
|
79
|
+
) -> None:
|
|
80
|
+
key = "async:meta"
|
|
81
|
+
now = datetime(2024, 1, 2, 3, 4, 5)
|
|
82
|
+
payload = {"color": Color.RED, "when": now}
|
|
83
|
+
assert await async_redis_service.set(key, payload) is True
|
|
84
|
+
|
|
85
|
+
raw = await async_redis_service.get_raw(key)
|
|
86
|
+
data = json.loads(raw) # type: ignore
|
|
87
|
+
assert data["color"] == "red"
|
|
88
|
+
assert data["when"] == now.isoformat()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def test_list_keys(async_redis_service: AsyncRedisService) -> None:
|
|
93
|
+
prefix = "async:listkeys:test:"
|
|
94
|
+
keys = [f"{prefix}{i}" for i in range(3)]
|
|
95
|
+
for k in keys:
|
|
96
|
+
assert await async_redis_service.set(k, {"i": 1}) is True
|
|
97
|
+
|
|
98
|
+
listed = await async_redis_service.list_keys(f"{prefix}*")
|
|
99
|
+
for k in keys:
|
|
100
|
+
assert k in listed
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_mget_mixed_results(async_redis_service: AsyncRedisService) -> None:
|
|
105
|
+
ok = UserModel(user_id=2, name="Bob")
|
|
106
|
+
k1 = "async:mget:ok"
|
|
107
|
+
k2 = "async:mget:not_json"
|
|
108
|
+
k3 = "async:mget:bad_validation"
|
|
109
|
+
k4 = "async:mget:none"
|
|
110
|
+
|
|
111
|
+
assert await async_redis_service.set(k1, ok) is True
|
|
112
|
+
assert await async_redis_service.set(k2, "{not-json}") is True
|
|
113
|
+
assert await async_redis_service.set(k3, json.dumps({"user_id": 3})) is True
|
|
114
|
+
|
|
115
|
+
results = await async_redis_service.mget([k1, k2, k3, k4], UserModel)
|
|
116
|
+
|
|
117
|
+
assert isinstance(results[0], UserModel)
|
|
118
|
+
assert isinstance(results[1], FailedValue)
|
|
119
|
+
assert isinstance(results[2], FailedValue)
|
|
120
|
+
assert results[3] is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.asyncio
|
|
124
|
+
async def test_delete(async_redis_service: AsyncRedisService) -> None:
|
|
125
|
+
key = "async:delete:test"
|
|
126
|
+
assert await async_redis_service.set(key, {"x": 1}) is True
|
|
127
|
+
assert await async_redis_service.delete(key) is True
|
|
128
|
+
assert await async_redis_service.get_raw(key) is None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.asyncio
|
|
132
|
+
async def test_context_manager_pattern(redis_container: RedisContainer) -> None:
|
|
133
|
+
"""Test that service can be used with proper async context management."""
|
|
134
|
+
host = redis_container.get_container_host_ip()
|
|
135
|
+
port = int(redis_container.get_exposed_port(6379))
|
|
136
|
+
|
|
137
|
+
config = RedisServiceConfig(redis_host=host, redis_port=port, redis_db=0)
|
|
138
|
+
service = AsyncRedisService(config)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
await service.connect()
|
|
142
|
+
assert await service.is_alive() is True
|
|
143
|
+
await service.set("test:key", "test:value")
|
|
144
|
+
assert await service.get_raw("test:key") == "test:value"
|
|
145
|
+
finally:
|
|
146
|
+
await service.close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_concurrent_operations(async_redis_service: AsyncRedisService) -> None:
|
|
151
|
+
"""Test multiple concurrent async operations."""
|
|
152
|
+
import asyncio
|
|
153
|
+
|
|
154
|
+
async def set_and_get(key: str, value: str) -> str | None:
|
|
155
|
+
await async_redis_service.set(key, value)
|
|
156
|
+
return await async_redis_service.get_raw(key)
|
|
157
|
+
|
|
158
|
+
tasks = [set_and_get(f"async:concurrent:{i}", f"value:{i}") for i in range(10)]
|
|
159
|
+
results = await asyncio.gather(*tasks)
|
|
160
|
+
|
|
161
|
+
for i, result in enumerate(results):
|
|
162
|
+
assert result == f"value:{i}"
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# mypy: disable-error-code=no-untyped-def
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from unittest.mock import AsyncMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import redis.asyncio as aioredis
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from redis_service.redis import (
|
|
14
|
+
AsyncRedisService,
|
|
15
|
+
FailedValue,
|
|
16
|
+
RedisServiceConfig,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UserModel(BaseModel):
|
|
21
|
+
user_id: int
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Color(Enum):
|
|
26
|
+
RED = 1
|
|
27
|
+
BLUE = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture(name="config")
|
|
31
|
+
def fixture_config() -> RedisServiceConfig:
|
|
32
|
+
return RedisServiceConfig(redis_host="localhost", redis_port=6379, redis_db=0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture(name="async_redis_client")
|
|
36
|
+
def fixture_async_redis_client() -> AsyncMock:
|
|
37
|
+
client = AsyncMock()
|
|
38
|
+
client.ping = AsyncMock(return_value=True)
|
|
39
|
+
client.aclose = AsyncMock()
|
|
40
|
+
return client
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture(name="service")
|
|
44
|
+
def fixture_service(
|
|
45
|
+
config: RedisServiceConfig, async_redis_client: AsyncMock
|
|
46
|
+
) -> AsyncRedisService:
|
|
47
|
+
with patch("redis_service.redis.aioredis.Redis", return_value=async_redis_client):
|
|
48
|
+
service = AsyncRedisService(config)
|
|
49
|
+
service._client = async_redis_client
|
|
50
|
+
return service
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_connect_success_initializes_client(config: RedisServiceConfig) -> None:
|
|
55
|
+
with patch("redis_service.redis.aioredis.Redis") as redis_instance:
|
|
56
|
+
mock_client = AsyncMock()
|
|
57
|
+
mock_client.ping = AsyncMock(return_value=True)
|
|
58
|
+
redis_instance.return_value = mock_client
|
|
59
|
+
|
|
60
|
+
service = AsyncRedisService(config)
|
|
61
|
+
await service.connect()
|
|
62
|
+
|
|
63
|
+
redis_instance.assert_called_once_with(
|
|
64
|
+
host=config.redis_host,
|
|
65
|
+
port=config.redis_port,
|
|
66
|
+
db=config.redis_db,
|
|
67
|
+
password=config.redis_password,
|
|
68
|
+
decode_responses=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def test_connect_failure_raises_connection_error(
|
|
74
|
+
config: RedisServiceConfig,
|
|
75
|
+
) -> None:
|
|
76
|
+
with patch("redis_service.redis.aioredis.Redis") as redis_instance:
|
|
77
|
+
mock_client = AsyncMock()
|
|
78
|
+
mock_client.ping = AsyncMock(side_effect=aioredis.ConnectionError())
|
|
79
|
+
redis_instance.return_value = mock_client
|
|
80
|
+
|
|
81
|
+
service = AsyncRedisService(config)
|
|
82
|
+
with pytest.raises(Exception) as exc:
|
|
83
|
+
await service.connect()
|
|
84
|
+
assert "Could not initialize Redis client" in str(exc.value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_is_alive_true(
|
|
89
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
90
|
+
) -> None:
|
|
91
|
+
async_redis_client.ping.return_value = True
|
|
92
|
+
assert await service.is_alive() is True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def test_is_alive_false_on_exception(
|
|
97
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
98
|
+
) -> None:
|
|
99
|
+
async_redis_client.ping.side_effect = aioredis.ConnectionError()
|
|
100
|
+
assert await service.is_alive() is False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_set_with_pydantic_model_serializes_and_sets(
|
|
105
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
106
|
+
) -> None:
|
|
107
|
+
model = UserModel(user_id=1, name="Alice")
|
|
108
|
+
async_redis_client.set = AsyncMock(return_value=True)
|
|
109
|
+
|
|
110
|
+
result = await service.set("user:1", model)
|
|
111
|
+
assert result is True
|
|
112
|
+
value = async_redis_client.set.call_args.args[1]
|
|
113
|
+
|
|
114
|
+
as_dict = json.loads(value)
|
|
115
|
+
assert as_dict == model.model_dump()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_set_with_dict_enum_datetime_uses_custom_encoder(
|
|
120
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
121
|
+
) -> None:
|
|
122
|
+
now = datetime(2024, 1, 2, 3, 4, 5)
|
|
123
|
+
payload = {"color": Color.RED, "when": now}
|
|
124
|
+
async_redis_client.set = AsyncMock(return_value=True)
|
|
125
|
+
|
|
126
|
+
assert await service.set("meta", payload) is True
|
|
127
|
+
value = async_redis_client.set.call_args.args[1]
|
|
128
|
+
as_dict = json.loads(value)
|
|
129
|
+
assert as_dict["color"] == "red"
|
|
130
|
+
assert as_dict["when"] == now.isoformat()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.asyncio
|
|
134
|
+
async def test_set_serialization_error_raises(service: AsyncRedisService) -> None:
|
|
135
|
+
bad = {"x": {1}}
|
|
136
|
+
with pytest.raises(Exception) as exc:
|
|
137
|
+
await service.set("k", bad)
|
|
138
|
+
assert "Failed to serialize value" in str(exc.value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_set_connection_error_maps(
|
|
143
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
144
|
+
) -> None:
|
|
145
|
+
async_redis_client.set = AsyncMock(side_effect=aioredis.ConnectionError("conn"))
|
|
146
|
+
with pytest.raises(Exception) as exc:
|
|
147
|
+
await service.set("k", {"a": 1})
|
|
148
|
+
assert "Error connecting to Redis host" in str(exc.value)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.mark.asyncio
|
|
152
|
+
async def test_set_redis_error_maps(
|
|
153
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
154
|
+
) -> None:
|
|
155
|
+
async_redis_client.set = AsyncMock(side_effect=aioredis.RedisError("oops"))
|
|
156
|
+
with pytest.raises(Exception) as exc:
|
|
157
|
+
await service.set("k", {"a": 1})
|
|
158
|
+
assert "Failed to set key" in str(exc.value)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.mark.asyncio
|
|
162
|
+
async def test_get_returns_none_for_missing(
|
|
163
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
164
|
+
) -> None:
|
|
165
|
+
async_redis_client.get = AsyncMock(return_value=None)
|
|
166
|
+
assert await service.get("missing", UserModel) is None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_get_success_deserializes_to_model(
|
|
171
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
172
|
+
) -> None:
|
|
173
|
+
model = UserModel(user_id=2, name="Bob")
|
|
174
|
+
async_redis_client.get = AsyncMock(return_value=json.dumps(model.model_dump()))
|
|
175
|
+
got = await service.get("user:2", UserModel)
|
|
176
|
+
assert isinstance(got, UserModel)
|
|
177
|
+
assert got == model
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pytest.mark.asyncio
|
|
181
|
+
async def test_get_json_decode_error_maps(
|
|
182
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
183
|
+
) -> None:
|
|
184
|
+
async_redis_client.get = AsyncMock(return_value="not-json")
|
|
185
|
+
with pytest.raises(Exception) as exc:
|
|
186
|
+
await service.get("k", UserModel)
|
|
187
|
+
assert "Failed to decode JSON" in str(exc.value)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@pytest.mark.asyncio
|
|
191
|
+
async def test_get_validation_error_maps(
|
|
192
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
193
|
+
) -> None:
|
|
194
|
+
async_redis_client.get = AsyncMock(return_value=json.dumps({"user_id": 3}))
|
|
195
|
+
with pytest.raises(Exception) as exc:
|
|
196
|
+
await service.get("k", UserModel)
|
|
197
|
+
assert "Validation failed" in str(exc.value)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pytest.mark.asyncio
|
|
201
|
+
async def test_get_connection_error_maps(
|
|
202
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
203
|
+
) -> None:
|
|
204
|
+
async_redis_client.get = AsyncMock(side_effect=aioredis.ConnectionError("down"))
|
|
205
|
+
with pytest.raises(Exception) as exc:
|
|
206
|
+
await service.get("k", UserModel)
|
|
207
|
+
assert "Error connecting to Redis host" in str(exc.value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@pytest.mark.asyncio
|
|
211
|
+
async def test_get_redis_error_maps(
|
|
212
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
213
|
+
) -> None:
|
|
214
|
+
async_redis_client.get = AsyncMock(side_effect=aioredis.RedisError("nope"))
|
|
215
|
+
with pytest.raises(Exception) as exc:
|
|
216
|
+
await service.get("k", UserModel)
|
|
217
|
+
assert "Failed to get key" in str(exc.value)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@pytest.mark.asyncio
|
|
221
|
+
async def test_get_raw_returns_value(
|
|
222
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
223
|
+
) -> None:
|
|
224
|
+
async_redis_client.get = AsyncMock(return_value="raw")
|
|
225
|
+
assert await service.get_raw("k") == "raw"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@pytest.mark.asyncio
|
|
229
|
+
async def test_get_raw_errors_map(
|
|
230
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
231
|
+
) -> None:
|
|
232
|
+
async_redis_client.get = AsyncMock(side_effect=aioredis.RedisError("err"))
|
|
233
|
+
with pytest.raises(Exception) as exc:
|
|
234
|
+
await service.get_raw("k")
|
|
235
|
+
assert "Failed to get key" in str(exc.value)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@pytest.mark.asyncio
|
|
239
|
+
async def test_list_keys_success(
|
|
240
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
241
|
+
) -> None:
|
|
242
|
+
async_redis_client.keys = AsyncMock(return_value=["a", "b"])
|
|
243
|
+
assert await service.list_keys("*") == ["a", "b"]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@pytest.mark.asyncio
|
|
247
|
+
async def test_list_keys_connection_error(
|
|
248
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
249
|
+
) -> None:
|
|
250
|
+
async_redis_client.keys = AsyncMock(side_effect=aioredis.ConnectionError("err"))
|
|
251
|
+
with pytest.raises(Exception) as exc:
|
|
252
|
+
await service.list_keys("*")
|
|
253
|
+
assert "Error connecting to Redis host" in str(exc.value)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@pytest.mark.asyncio
|
|
257
|
+
async def test_list_keys_redis_error(
|
|
258
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
259
|
+
) -> None:
|
|
260
|
+
async_redis_client.keys = AsyncMock(side_effect=aioredis.RedisError("err"))
|
|
261
|
+
with pytest.raises(Exception) as exc:
|
|
262
|
+
await service.list_keys("*")
|
|
263
|
+
assert "Failed to list keys" in str(exc.value)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@pytest.mark.asyncio
|
|
267
|
+
async def test_mget_empty_returns_empty(service: AsyncRedisService) -> None:
|
|
268
|
+
assert await service.mget([], UserModel) == []
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@pytest.mark.asyncio
|
|
272
|
+
async def test_mget_success_and_failures(
|
|
273
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
274
|
+
) -> None:
|
|
275
|
+
good = UserModel(user_id=1, name="A").model_dump()
|
|
276
|
+
bad_json = "{not-json}"
|
|
277
|
+
bad_validation = json.dumps({"user_id": 2})
|
|
278
|
+
none_value = None
|
|
279
|
+
async_redis_client.mget = AsyncMock(
|
|
280
|
+
return_value=[
|
|
281
|
+
json.dumps(good),
|
|
282
|
+
bad_json,
|
|
283
|
+
bad_validation,
|
|
284
|
+
none_value,
|
|
285
|
+
]
|
|
286
|
+
)
|
|
287
|
+
keys = ["k1", "k2", "k3", "k4"]
|
|
288
|
+
results = await service.mget(keys, UserModel)
|
|
289
|
+
|
|
290
|
+
assert isinstance(results[0], UserModel)
|
|
291
|
+
assert isinstance(results[1], FailedValue)
|
|
292
|
+
assert results[1].key == "k2"
|
|
293
|
+
assert isinstance(results[2], FailedValue)
|
|
294
|
+
assert results[2].key == "k3"
|
|
295
|
+
assert results[3] is None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@pytest.mark.asyncio
|
|
299
|
+
async def test_mget_connection_error_maps(
|
|
300
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
301
|
+
) -> None:
|
|
302
|
+
async_redis_client.mget = AsyncMock(side_effect=aioredis.ConnectionError("down"))
|
|
303
|
+
with pytest.raises(Exception) as exc:
|
|
304
|
+
await service.mget(["a"], UserModel)
|
|
305
|
+
assert "Error connecting to Redis host" in str(exc.value)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@pytest.mark.asyncio
|
|
309
|
+
async def test_mget_redis_error_maps(
|
|
310
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
311
|
+
) -> None:
|
|
312
|
+
async_redis_client.mget = AsyncMock(side_effect=aioredis.RedisError("err"))
|
|
313
|
+
with pytest.raises(Exception) as exc:
|
|
314
|
+
await service.mget(["a"], UserModel)
|
|
315
|
+
assert "Failed to mget keys" in str(exc.value)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@pytest.mark.asyncio
|
|
319
|
+
async def test_delete_success(
|
|
320
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
321
|
+
) -> None:
|
|
322
|
+
async_redis_client.delete = AsyncMock(return_value=1)
|
|
323
|
+
assert await service.delete("k") is True
|
|
324
|
+
async_redis_client.delete.assert_called_once_with("k")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@pytest.mark.asyncio
|
|
328
|
+
async def test_delete_connection_error_maps(
|
|
329
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
330
|
+
) -> None:
|
|
331
|
+
async_redis_client.delete = AsyncMock(side_effect=aioredis.ConnectionError("down"))
|
|
332
|
+
with pytest.raises(Exception) as exc:
|
|
333
|
+
await service.delete("k")
|
|
334
|
+
assert "Error connecting to Redis host" in str(exc.value)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@pytest.mark.asyncio
|
|
338
|
+
async def test_delete_redis_error_maps(
|
|
339
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
340
|
+
) -> None:
|
|
341
|
+
async_redis_client.delete = AsyncMock(side_effect=aioredis.RedisError("err"))
|
|
342
|
+
with pytest.raises(Exception) as exc:
|
|
343
|
+
await service.delete("k")
|
|
344
|
+
assert "Failed to delete key" in str(exc.value)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pytest.mark.asyncio
|
|
348
|
+
async def test_set_plain_string_value(
|
|
349
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
350
|
+
) -> None:
|
|
351
|
+
async_redis_client.set = AsyncMock(return_value=True)
|
|
352
|
+
assert await service.set("greeting", "hello") is True
|
|
353
|
+
value = (
|
|
354
|
+
async_redis_client.set.call_args.args[1]
|
|
355
|
+
if async_redis_client.set.call_args.args
|
|
356
|
+
else async_redis_client.set.call_args[0][1]
|
|
357
|
+
)
|
|
358
|
+
assert value == "hello"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@pytest.mark.asyncio
|
|
362
|
+
async def test_type_validation_errors(service: AsyncRedisService) -> None:
|
|
363
|
+
with pytest.raises(ValueError, match="Key must be a string"):
|
|
364
|
+
await service.set(123, "x") # type: ignore[arg-type]
|
|
365
|
+
|
|
366
|
+
with pytest.raises(ValueError, match="Key must be a string"):
|
|
367
|
+
await service.get(123, UserModel) # type: ignore[arg-type]
|
|
368
|
+
|
|
369
|
+
with pytest.raises(ValueError, match="Key must be a string"):
|
|
370
|
+
await service.get_raw(123) # type: ignore[arg-type]
|
|
371
|
+
|
|
372
|
+
with pytest.raises(ValueError, match="Pattern must be a string"):
|
|
373
|
+
await service.list_keys(123) # type: ignore[arg-type]
|
|
374
|
+
|
|
375
|
+
with pytest.raises(ValueError, match="Keys must be a list"):
|
|
376
|
+
await service.mget("not-a-list", UserModel) # type: ignore[arg-type]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@pytest.mark.asyncio
|
|
380
|
+
async def test_close_connection(
|
|
381
|
+
service: AsyncRedisService, async_redis_client: AsyncMock
|
|
382
|
+
) -> None:
|
|
383
|
+
await service.close()
|
|
384
|
+
async_redis_client.aclose.assert_called_once()
|
|
File without changes
|
|
File without changes
|