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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cledar-sdk
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Cledar Python SDK
5
5
  Author: Cledar
6
6
  License-File: LICENSE
@@ -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=GFzNRpCvPRl-QoOFiDTmj1ncgUoU_HaJV74zVtdwG9M,10176
56
- redis_service/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
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=d8hqkVokVs6eMsteJEsEYZ56nb7WhTQ8Gc_sT4zkSRY,9974
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.2.1.dist-info/METADATA,sha256=9087LMoW_Hd1TC5jwPFbg_w8a43r1ug9ssJJ_02gEzA,6752
78
- cledar_sdk-1.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
79
- cledar_sdk-1.2.1.dist-info/licenses/LICENSE,sha256=Pz2eACSxkhsGfW9_iN60pgy-enjnbGTj8df8O3ebnQQ,16726
80
- cledar_sdk-1.2.1.dist-info/RECORD,,
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 Example
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 # Custom exceptions
89
- ├── logger.py # Module logger
90
- ├── model.py # Base config type for RedisConfigStore
91
- ├── redis.py # Main RedisService implementation
92
- ├── redis_config_store.py # Config store with caching and watchers
93
- ├── example.py # Small example of using RedisConfigStore
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 # Unit tests (mocked Redis)
96
- └── test_integration_redis.py # Integration tests with testcontainers
97
- └── README.md # This file
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**: 30 unit tests
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**: 8 integration tests
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,15 @@
1
+ from .redis import (
2
+ AsyncRedisService,
3
+ CustomEncoder,
4
+ FailedValue,
5
+ RedisService,
6
+ RedisServiceConfig,
7
+ )
8
+
9
+ __all__ = [
10
+ "AsyncRedisService",
11
+ "CustomEncoder",
12
+ "FailedValue",
13
+ "RedisService",
14
+ "RedisServiceConfig",
15
+ ]
@@ -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()