cledar-sdk 2.0.2__py3-none-any.whl → 2.0.3__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/__init__.py +0 -0
- cledar/kafka/README.md +239 -0
- cledar/kafka/__init__.py +40 -0
- cledar/kafka/clients/base.py +98 -0
- cledar/kafka/clients/consumer.py +110 -0
- cledar/kafka/clients/producer.py +80 -0
- cledar/kafka/config/schemas.py +178 -0
- cledar/kafka/exceptions.py +22 -0
- cledar/kafka/handlers/dead_letter.py +82 -0
- cledar/kafka/handlers/parser.py +49 -0
- cledar/kafka/logger.py +3 -0
- cledar/kafka/models/input.py +13 -0
- cledar/kafka/models/message.py +10 -0
- cledar/kafka/models/output.py +8 -0
- cledar/kafka/tests/.env.test.kafka +3 -0
- cledar/kafka/tests/README.md +216 -0
- cledar/kafka/tests/conftest.py +104 -0
- cledar/kafka/tests/integration/__init__.py +1 -0
- cledar/kafka/tests/integration/conftest.py +78 -0
- cledar/kafka/tests/integration/helpers.py +47 -0
- cledar/kafka/tests/integration/test_consumer_integration.py +375 -0
- cledar/kafka/tests/integration/test_integration.py +394 -0
- cledar/kafka/tests/integration/test_producer_consumer_interaction.py +388 -0
- cledar/kafka/tests/integration/test_producer_integration.py +217 -0
- cledar/kafka/tests/unit/__init__.py +1 -0
- cledar/kafka/tests/unit/test_base_kafka_client.py +391 -0
- cledar/kafka/tests/unit/test_config_validation.py +609 -0
- cledar/kafka/tests/unit/test_dead_letter_handler.py +443 -0
- cledar/kafka/tests/unit/test_error_handling.py +674 -0
- cledar/kafka/tests/unit/test_input_parser.py +310 -0
- cledar/kafka/tests/unit/test_input_parser_comprehensive.py +489 -0
- cledar/kafka/tests/unit/test_utils.py +25 -0
- cledar/kafka/tests/unit/test_utils_comprehensive.py +408 -0
- cledar/kafka/utils/callbacks.py +19 -0
- cledar/kafka/utils/messages.py +28 -0
- cledar/kafka/utils/topics.py +2 -0
- cledar/kserve/README.md +352 -0
- cledar/kserve/__init__.py +3 -0
- cledar/kserve/tests/__init__.py +0 -0
- cledar/kserve/tests/test_utils.py +64 -0
- cledar/kserve/utils.py +27 -0
- cledar/logging/README.md +53 -0
- cledar/logging/__init__.py +3 -0
- cledar/logging/tests/test_universal_plaintext_formatter.py +249 -0
- cledar/logging/universal_plaintext_formatter.py +94 -0
- cledar/monitoring/README.md +71 -0
- cledar/monitoring/__init__.py +3 -0
- cledar/monitoring/monitoring_server.py +112 -0
- cledar/monitoring/tests/integration/test_monitoring_server_int.py +162 -0
- cledar/monitoring/tests/test_monitoring_server.py +59 -0
- cledar/nonce/README.md +99 -0
- cledar/nonce/__init__.py +3 -0
- cledar/nonce/nonce_service.py +36 -0
- cledar/nonce/tests/__init__.py +0 -0
- cledar/nonce/tests/test_nonce_service.py +136 -0
- cledar/redis/README.md +536 -0
- cledar/redis/__init__.py +15 -0
- cledar/redis/async_example.py +111 -0
- cledar/redis/example.py +37 -0
- cledar/redis/exceptions.py +22 -0
- cledar/redis/logger.py +3 -0
- cledar/redis/model.py +10 -0
- cledar/redis/redis.py +525 -0
- cledar/redis/redis_config_store.py +252 -0
- cledar/redis/tests/test_async_integration_redis.py +158 -0
- cledar/redis/tests/test_async_redis_service.py +380 -0
- cledar/redis/tests/test_integration_redis.py +119 -0
- cledar/redis/tests/test_redis_service.py +319 -0
- cledar/storage/README.md +529 -0
- cledar/storage/__init__.py +4 -0
- cledar/storage/constants.py +3 -0
- cledar/storage/exceptions.py +50 -0
- cledar/storage/models.py +19 -0
- cledar/storage/object_storage.py +955 -0
- cledar/storage/tests/conftest.py +18 -0
- cledar/storage/tests/test_abfs.py +164 -0
- cledar/storage/tests/test_integration_filesystem.py +359 -0
- cledar/storage/tests/test_integration_s3.py +453 -0
- cledar/storage/tests/test_local.py +384 -0
- cledar/storage/tests/test_s3.py +521 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.0.3.dist-info}/METADATA +1 -1
- cledar_sdk-2.0.3.dist-info/RECORD +84 -0
- cledar_sdk-2.0.2.dist-info/RECORD +0 -4
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.0.3.dist-info}/WHEEL +0 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.0.3.dist-info}/licenses/LICENSE +0 -0
cledar/redis/README.md
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
# Redis Service
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The `cledar.redis` package provides a typed, high-level interface over Redis for simple key/value storage with JSON serialization, plus helpers for bulk reads and a lightweight configuration store built on Redis pub/sub and keyspace notifications.
|
|
6
|
+
|
|
7
|
+
### Key Features
|
|
8
|
+
|
|
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
|
|
11
|
+
- **Safe Serialization**: Custom JSON encoder for `Enum` (to lowercase names) and `datetime` (ISO 8601)
|
|
12
|
+
- **Ergonomic Helpers**: `get`, `get_raw`, `set`, `list_keys`, `mget`, `delete`
|
|
13
|
+
- **Bulk Reads**: `mget` returns a list with typed results, `None`, or `FailedValue` for per-key errors
|
|
14
|
+
- **Error Mapping**: Consistent custom exceptions for connection, serialization, deserialization, and operation errors
|
|
15
|
+
- **Config Store**: `RedisConfigStore` with local cache, version tracking, and watchers via keyspace events
|
|
16
|
+
- **Well Tested**: Fast unit tests and Redis-backed integration tests
|
|
17
|
+
|
|
18
|
+
### Use Cases
|
|
19
|
+
|
|
20
|
+
- Caching results of computations as JSON documents
|
|
21
|
+
- Persisting lightweight application state across processes
|
|
22
|
+
- Reading and writing typed configuration objects
|
|
23
|
+
- Bulk retrieval of many keys while tolerating per-key failures
|
|
24
|
+
- Observing and reacting to configuration changes in near real-time
|
|
25
|
+
- Asynchronous I/O for high-performance applications (FastAPI, aiohttp, etc.)
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
This package is part of the `cledar-python-sdk`. Install dependencies using:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Install with uv (recommended)
|
|
33
|
+
uv sync --all-groups
|
|
34
|
+
|
|
35
|
+
# Or with pip (editable install from repo root)
|
|
36
|
+
pip install -e .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage Examples
|
|
40
|
+
|
|
41
|
+
### Synchronous Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from pydantic import BaseModel
|
|
45
|
+
from cledar.redis import RedisService, RedisServiceConfig
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UserModel(BaseModel):
|
|
49
|
+
user_id: int
|
|
50
|
+
name: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Configure and create service
|
|
54
|
+
config = RedisServiceConfig(
|
|
55
|
+
redis_host="localhost",
|
|
56
|
+
redis_port=6379,
|
|
57
|
+
redis_db=0,
|
|
58
|
+
)
|
|
59
|
+
service = RedisService(config)
|
|
60
|
+
|
|
61
|
+
# Health check
|
|
62
|
+
assert service.is_alive() is True
|
|
63
|
+
|
|
64
|
+
# Write a typed value (automatically serialized to JSON)
|
|
65
|
+
user = UserModel(user_id=1, name="Alice")
|
|
66
|
+
service.set("user:1", user)
|
|
67
|
+
|
|
68
|
+
# Read and validate back into the model
|
|
69
|
+
loaded = service.get("user:1", UserModel)
|
|
70
|
+
print(loaded) # UserModel(user_id=1, name='Alice')
|
|
71
|
+
|
|
72
|
+
# Raw access (no validation/decoding beyond Redis decode_responses)
|
|
73
|
+
service.set("greeting", "hello")
|
|
74
|
+
print(service.get_raw("greeting")) # "hello"
|
|
75
|
+
|
|
76
|
+
# List keys by pattern and bulk-fetch
|
|
77
|
+
keys = service.list_keys("user:*")
|
|
78
|
+
bulk = service.mget(keys, UserModel)
|
|
79
|
+
# bulk is a list of UserModel | None | FailedValue
|
|
80
|
+
|
|
81
|
+
# Delete
|
|
82
|
+
service.delete("greeting")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Asynchronous Usage
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import asyncio
|
|
89
|
+
from pydantic import BaseModel
|
|
90
|
+
from cledar.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 cledar.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
|
+
|
|
201
|
+
## Development
|
|
202
|
+
|
|
203
|
+
### Project Structure
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
cledar/redis/
|
|
207
|
+
├── __init__.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
|
|
214
|
+
├── tests/
|
|
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
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Running Linters
|
|
223
|
+
|
|
224
|
+
The SDK configures common linters in `pyproject.toml`.
|
|
225
|
+
|
|
226
|
+
### Installing Linters
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install pylint mypy black
|
|
230
|
+
|
|
231
|
+
# Or with uv
|
|
232
|
+
uv pip install pylint mypy black
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Running Linters
|
|
236
|
+
|
|
237
|
+
Run these from the SDK root directory:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# From the SDK root directory
|
|
241
|
+
cd /path/to/cledar-python-sdk
|
|
242
|
+
|
|
243
|
+
# Pylint
|
|
244
|
+
pylint redis_service/
|
|
245
|
+
|
|
246
|
+
# Mypy (strict mode configured in pyproject)
|
|
247
|
+
mypy redis_service/
|
|
248
|
+
|
|
249
|
+
# Black (check and/or format)
|
|
250
|
+
black --check redis_service/
|
|
251
|
+
black redis_service/
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Run All Linters
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
pylint redis_service/ && \
|
|
258
|
+
mypy redis_service/ && \
|
|
259
|
+
black --check redis_service/
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Running Unit Tests
|
|
263
|
+
|
|
264
|
+
Unit tests use `unittest.mock` to isolate logic without a real Redis instance.
|
|
265
|
+
|
|
266
|
+
### Run All Unit Tests
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
# From the SDK root directory
|
|
270
|
+
cd /path/to/cledar-python-sdk
|
|
271
|
+
|
|
272
|
+
PYTHONPATH=$PWD uv run pytest redis_service/tests/test_redis_service.py -v
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Run Specific Test
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
PYTHONPATH=$PWD uv run pytest redis_service/tests/test_redis_service.py::test_set_with_pydantic_model_serializes_and_sets -v
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Unit Test Details
|
|
282
|
+
|
|
283
|
+
- **Test Framework**: pytest, pytest-asyncio
|
|
284
|
+
- **Mocking**: unittest.mock (sync), AsyncMock (async)
|
|
285
|
+
- **Test Count**: 60 unit tests (30 sync + 30 async)
|
|
286
|
+
|
|
287
|
+
## Running Integration Tests
|
|
288
|
+
|
|
289
|
+
Integration tests use [testcontainers](https://testcontainers-python.readthedocs.io/) to run a real Redis container.
|
|
290
|
+
|
|
291
|
+
### Prerequisites
|
|
292
|
+
|
|
293
|
+
**Required**:
|
|
294
|
+
- Docker installed and running
|
|
295
|
+
- Network access to pull Docker images
|
|
296
|
+
|
|
297
|
+
### Run Integration Tests
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
# From the SDK root directory
|
|
301
|
+
cd /path/to/cledar-python-sdk
|
|
302
|
+
|
|
303
|
+
PYTHONPATH=$PWD uv run pytest redis_service/tests/test_integration_redis.py -v
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Integration Test Details
|
|
307
|
+
|
|
308
|
+
- **Test Framework**: pytest, pytest-asyncio + testcontainers
|
|
309
|
+
- **Container**: Redis
|
|
310
|
+
- **Image**: `redis:7.2-alpine`
|
|
311
|
+
- **Test Count**: 17 integration tests (8 sync + 9 async)
|
|
312
|
+
|
|
313
|
+
### Run All Tests (Unit + Integration)
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
PYTHONPATH=$PWD uv run pytest redis_service/tests/ -v
|
|
317
|
+
|
|
318
|
+
# With coverage
|
|
319
|
+
PYTHONPATH=$PWD uv run pytest redis_service/tests/ \
|
|
320
|
+
--cov=redis_service \
|
|
321
|
+
--cov-report=html \
|
|
322
|
+
--cov-report=term \
|
|
323
|
+
-v
|
|
324
|
+
|
|
325
|
+
open htmlcov/index.html
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## CI/CD Integration
|
|
329
|
+
|
|
330
|
+
### GitLab CI Example
|
|
331
|
+
|
|
332
|
+
```yaml
|
|
333
|
+
test-unit:
|
|
334
|
+
stage: test
|
|
335
|
+
image: python:3.12
|
|
336
|
+
script:
|
|
337
|
+
- pip install uv
|
|
338
|
+
- uv sync --all-groups
|
|
339
|
+
- PYTHONPATH=$PWD uv run pytest redis_service/tests/test_redis_service.py -v
|
|
340
|
+
|
|
341
|
+
test-integration:
|
|
342
|
+
stage: test
|
|
343
|
+
image: python:3.12
|
|
344
|
+
services:
|
|
345
|
+
- docker:dind
|
|
346
|
+
variables:
|
|
347
|
+
DOCKER_HOST: tcp://docker:2375
|
|
348
|
+
DOCKER_TLS_CERTDIR: ""
|
|
349
|
+
script:
|
|
350
|
+
- pip install uv
|
|
351
|
+
- uv sync --all-groups
|
|
352
|
+
- PYTHONPATH=$PWD uv run pytest redis_service/tests/test_integration_redis.py -v
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### GitHub Actions Example
|
|
356
|
+
|
|
357
|
+
```yaml
|
|
358
|
+
name: Tests
|
|
359
|
+
on: [push, pull_request]
|
|
360
|
+
|
|
361
|
+
jobs:
|
|
362
|
+
unit-tests:
|
|
363
|
+
runs-on: ubuntu-latest
|
|
364
|
+
steps:
|
|
365
|
+
- uses: actions/checkout@v3
|
|
366
|
+
- uses: actions/setup-python@v4
|
|
367
|
+
with:
|
|
368
|
+
python-version: '3.12'
|
|
369
|
+
- name: Install dependencies
|
|
370
|
+
run: |
|
|
371
|
+
pip install uv
|
|
372
|
+
uv sync --all-groups
|
|
373
|
+
- name: Run unit tests
|
|
374
|
+
run: PYTHONPATH=$PWD uv run pytest redis_service/tests/test_redis_service.py -v
|
|
375
|
+
|
|
376
|
+
integration-tests:
|
|
377
|
+
runs-on: ubuntu-latest
|
|
378
|
+
steps:
|
|
379
|
+
- uses: actions/checkout@v3
|
|
380
|
+
- uses: actions/setup-python@v4
|
|
381
|
+
with:
|
|
382
|
+
python-version: '3.12'
|
|
383
|
+
- name: Install dependencies
|
|
384
|
+
run: |
|
|
385
|
+
pip install uv
|
|
386
|
+
uv sync --all-groups
|
|
387
|
+
- name: Run integration tests
|
|
388
|
+
run: PYTHONPATH=$PWD uv run pytest redis_service/tests/test_integration_redis.py -v
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
## API Reference
|
|
392
|
+
|
|
393
|
+
### RedisServiceConfig
|
|
394
|
+
|
|
395
|
+
Dataclass configuring the Redis connection.
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
from dataclasses import dataclass
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@dataclass
|
|
402
|
+
class RedisServiceConfig:
|
|
403
|
+
redis_host: str
|
|
404
|
+
redis_port: int
|
|
405
|
+
redis_db: int = 0
|
|
406
|
+
redis_password: str | None = None
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### RedisService (Synchronous)
|
|
410
|
+
|
|
411
|
+
High-level service over `redis.Redis` with JSON handling and typed reads.
|
|
412
|
+
|
|
413
|
+
#### Methods
|
|
414
|
+
|
|
415
|
+
- `is_alive() -> bool` — Ping Redis to check connectivity
|
|
416
|
+
- `set(key: str, value: Any) -> bool` — Serialize and store a value; supports dict/list, Pydantic models, primitives
|
|
417
|
+
- `get(key: str, model: type[T]) -> T | None` — Read and validate JSON into the given Pydantic model
|
|
418
|
+
- `get_raw(key: str) -> Any | None` — Read raw value (usually string) without validation
|
|
419
|
+
- `list_keys(pattern: str) -> list[str]` — List keys matching a glob-like pattern
|
|
420
|
+
- `mget(keys: list[str], model: type[T]) -> list[T | None | FailedValue]` — Bulk read with per-key error details
|
|
421
|
+
- `delete(key: str) -> bool` — Delete a key; returns True if a key was removed
|
|
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
|
+
|
|
441
|
+
#### Exceptions
|
|
442
|
+
|
|
443
|
+
- `RedisConnectionError` — Connection/transport errors
|
|
444
|
+
- `RedisSerializationError` — Failures before sending to Redis (e.g., unsupported object)
|
|
445
|
+
- `RedisDeserializationError` — Invalid JSON or model validation errors on read
|
|
446
|
+
- `RedisOperationError` — Other Redis command errors
|
|
447
|
+
|
|
448
|
+
### CustomEncoder
|
|
449
|
+
|
|
450
|
+
`json.JSONEncoder` subclass used internally:
|
|
451
|
+
|
|
452
|
+
- `Enum` → lowercase of member name (e.g., `Color.RED` → `"red"`)
|
|
453
|
+
- `datetime` → ISO 8601 string (e.g., `"2025-01-01T00:00:00"`)
|
|
454
|
+
|
|
455
|
+
### FailedValue
|
|
456
|
+
|
|
457
|
+
Dataclass used by `mget` to signal per-key errors without failing the whole call:
|
|
458
|
+
|
|
459
|
+
```python
|
|
460
|
+
from dataclasses import dataclass
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@dataclass
|
|
464
|
+
class FailedValue:
|
|
465
|
+
key: str
|
|
466
|
+
error: Exception
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
## RedisConfigStore
|
|
470
|
+
|
|
471
|
+
`RedisConfigStore` provides a simple configuration layer on top of Redis:
|
|
472
|
+
|
|
473
|
+
- Caches last known values per key (string or list-backed history)
|
|
474
|
+
- Tracks a simple per-key version (`1` for string, list length for list)
|
|
475
|
+
- Watches keys using Redis keyspace notifications and updates local cache
|
|
476
|
+
- Provides `fetch`, `update`, `delete`, `versions`, `cached_version`, and `watch`
|
|
477
|
+
|
|
478
|
+
### Usage
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
from dataclasses import dataclass
|
|
482
|
+
from redis import Redis
|
|
483
|
+
from cledar.redis.redis_config_store import RedisConfigStore
|
|
484
|
+
from redis_service.model import BaseConfigClass
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@dataclass
|
|
488
|
+
class ExampleConfig(BaseConfigClass):
|
|
489
|
+
name: str
|
|
490
|
+
index: int
|
|
491
|
+
data: dict[str, str]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
r = Redis(host="localhost", port=6379, db=0, decode_responses=False)
|
|
495
|
+
store = RedisConfigStore(r, prefix="app:")
|
|
496
|
+
|
|
497
|
+
key = "example_config"
|
|
498
|
+
cfg = ExampleConfig(name="demo", index=1, data={})
|
|
499
|
+
|
|
500
|
+
# Set/update (appends new version for list-backed keys)
|
|
501
|
+
store[key] = cfg
|
|
502
|
+
|
|
503
|
+
# Fetch typed config and current cached version
|
|
504
|
+
fetched = store.fetch(ExampleConfig, key)
|
|
505
|
+
version = store.cached_version(key)
|
|
506
|
+
|
|
507
|
+
# Watch for updates (optional callback)
|
|
508
|
+
store.watch(key)
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Note: Keyspace notifications must be enabled in Redis to receive events, for example:
|
|
512
|
+
|
|
513
|
+
```bash
|
|
514
|
+
# Enable keyspace and keyevent notifications (example; tailor to your needs)
|
|
515
|
+
redis-cli CONFIG SET notify-keyspace-events Ex
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
## Running Pre-commit Checks
|
|
519
|
+
|
|
520
|
+
```bash
|
|
521
|
+
uv run black redis_service/
|
|
522
|
+
uv run mypy redis_service/
|
|
523
|
+
uv run pylint redis_service/
|
|
524
|
+
PYTHONPATH=$PWD uv run pytest redis_service/tests/ -v
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
## License
|
|
528
|
+
|
|
529
|
+
See the main repository LICENSE file.
|
|
530
|
+
|
|
531
|
+
## Support
|
|
532
|
+
|
|
533
|
+
For issues, questions, or contributions, please refer to the main repository's contribution guidelines.
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
|
cledar/redis/__init__.py
ADDED
|
@@ -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 cledar.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())
|
cledar/redis/example.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .model import BaseConfigClass
|
|
5
|
+
from .redis_config_store import RedisConfigStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ExampleConfig(BaseConfigClass):
|
|
10
|
+
name: str
|
|
11
|
+
index: int
|
|
12
|
+
data: dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG = ExampleConfig(name="name", index=0, data={})
|
|
16
|
+
CONFIG_KEY = "example_config"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigProvider:
|
|
20
|
+
def __init__(self, redis_config_store: RedisConfigStore) -> None:
|
|
21
|
+
self.redis_config_store = redis_config_store
|
|
22
|
+
if self.redis_config_store.fetch(ExampleConfig, CONFIG_KEY) is None:
|
|
23
|
+
self.redis_config_store[CONFIG_KEY] = DEFAULT_CONFIG
|
|
24
|
+
|
|
25
|
+
def get_example_config(self) -> ExampleConfig:
|
|
26
|
+
return (
|
|
27
|
+
self.redis_config_store.fetch(ExampleConfig, CONFIG_KEY) or DEFAULT_CONFIG
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def get_example_config_version(self) -> int:
|
|
31
|
+
return self.redis_config_store.cached_version(CONFIG_KEY) or -1
|
|
32
|
+
|
|
33
|
+
def set_example_config(self, config: ExampleConfig | None) -> None:
|
|
34
|
+
if config is None:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
self.redis_config_store[CONFIG_KEY] = config
|