muscles-data-redis 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-redis
3
+ Version: 0.1.0
4
+ Summary: Redis KeyValuePort, LockPort and StreamPort adapter for muscles-data
5
+ Author-email: "Denis B." <denis@butko.info>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: muscles-data<1.0.0,>=0.1.0
9
+ Requires-Dist: redis<9.0,>=5.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-redis
14
+
15
+ Redis adapter package for `muscles-data`.
16
+
17
+ This package is intentionally separate from `muscles-data`: the core package
18
+ owns typed ports, resource runtime and diagnostics, while this package owns
19
+ Redis-backed `KeyValuePort`, `LockPort` and `StreamPort` implementations.
20
+
21
+ ## Related packages
22
+
23
+ - Core runtime and port contracts:
24
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
25
+ - Elasticsearch search adapter:
26
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
27
+ - OpenSearch search adapter:
28
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
29
+ - Qdrant vector adapter:
30
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
31
+ - MongoDB document-store adapter:
32
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
33
+ - S3 object-store adapter:
34
+ [`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
35
+ - SQLAlchemy direct SQL resource adapter:
36
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
37
+ - Executable example:
38
+ [`example_data_redis_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_redis_1)
39
+
40
+ ## Usage
41
+
42
+ Register the factory in the project composition root:
43
+
44
+ ```python
45
+ from muscles_data.catalog import DataAdapterCatalog
46
+ from muscles_data.ports import KeyValuePort
47
+ from muscles_data.runtime import DataRuntime
48
+ from muscles_data_redis import RedisDataFactory
49
+
50
+ catalog = DataAdapterCatalog.with_defaults()
51
+ catalog.register(RedisDataFactory())
52
+
53
+ runtime = DataRuntime(config=config, catalog=catalog)
54
+ cache = runtime.require_port("cache.redis", KeyValuePort)
55
+ ```
56
+
57
+ Resource config stays in the project:
58
+
59
+ ```yaml
60
+ data:
61
+ resources:
62
+ cache.redis:
63
+ type: redis
64
+ url_env: REDIS_URL
65
+ namespace: app
66
+ timeout: 3
67
+ stream_group: workers
68
+ consumer: index-worker
69
+ ```
70
+
71
+ The adapter creates the Redis client lazily by key-value, lock, stream, explicit
72
+ native access or `data.doctor` operations. Application code should use
73
+ `KeyValuePort`, `LockPort` and `StreamPort`; direct client access is only an
74
+ advanced escape hatch with `native_client: true`.
75
+
76
+ Streams use `XADD`, `XREADGROUP` and `XACK`. The configured consumer group is
77
+ created on first read and messages use the versioned
78
+ `muscles.data.message.v1` envelope. Retry and dead-letter decisions stay with
79
+ the application worker.
80
+
81
+ See `muscular-example/example_data_redis_1` for an executable example.
@@ -0,0 +1,69 @@
1
+ # muscles-data-redis
2
+
3
+ Redis adapter package for `muscles-data`.
4
+
5
+ This package is intentionally separate from `muscles-data`: the core package
6
+ owns typed ports, resource runtime and diagnostics, while this package owns
7
+ Redis-backed `KeyValuePort`, `LockPort` and `StreamPort` implementations.
8
+
9
+ ## Related packages
10
+
11
+ - Core runtime and port contracts:
12
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
13
+ - Elasticsearch search adapter:
14
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
15
+ - OpenSearch search adapter:
16
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
17
+ - Qdrant vector adapter:
18
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
19
+ - MongoDB document-store adapter:
20
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
21
+ - S3 object-store adapter:
22
+ [`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
23
+ - SQLAlchemy direct SQL resource adapter:
24
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
25
+ - Executable example:
26
+ [`example_data_redis_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_redis_1)
27
+
28
+ ## Usage
29
+
30
+ Register the factory in the project composition root:
31
+
32
+ ```python
33
+ from muscles_data.catalog import DataAdapterCatalog
34
+ from muscles_data.ports import KeyValuePort
35
+ from muscles_data.runtime import DataRuntime
36
+ from muscles_data_redis import RedisDataFactory
37
+
38
+ catalog = DataAdapterCatalog.with_defaults()
39
+ catalog.register(RedisDataFactory())
40
+
41
+ runtime = DataRuntime(config=config, catalog=catalog)
42
+ cache = runtime.require_port("cache.redis", KeyValuePort)
43
+ ```
44
+
45
+ Resource config stays in the project:
46
+
47
+ ```yaml
48
+ data:
49
+ resources:
50
+ cache.redis:
51
+ type: redis
52
+ url_env: REDIS_URL
53
+ namespace: app
54
+ timeout: 3
55
+ stream_group: workers
56
+ consumer: index-worker
57
+ ```
58
+
59
+ The adapter creates the Redis client lazily by key-value, lock, stream, explicit
60
+ native access or `data.doctor` operations. Application code should use
61
+ `KeyValuePort`, `LockPort` and `StreamPort`; direct client access is only an
62
+ advanced escape hatch with `native_client: true`.
63
+
64
+ Streams use `XADD`, `XREADGROUP` and `XACK`. The configured consumer group is
65
+ created on first read and messages use the versioned
66
+ `muscles.data.message.v1` envelope. Retry and dead-letter decisions stay with
67
+ the application worker.
68
+
69
+ See `muscular-example/example_data_redis_1` for an executable example.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "muscles-data-redis"
7
+ version = "0.1.0"
8
+ description = "Redis KeyValuePort, LockPort and StreamPort adapter for muscles-data"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Denis B.", email = "denis@butko.info" }]
12
+ dependencies = [
13
+ "muscles-data>=0.1.0,<1.0.0",
14
+ "redis>=5.0,<9.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.0,<9.0",
20
+ ]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ markers = [
25
+ "integration: tests that require the local backend integration stack",
26
+ ]
27
+
28
+ [tool.setuptools]
29
+ package-dir = {"" = "src"}
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from .adapter import (
4
+ RedisAdapterError,
5
+ RedisClientMissingError,
6
+ RedisConfigError,
7
+ RedisConnectionError,
8
+ RedisDataAdapter,
9
+ RedisDataFactory,
10
+ )
11
+
12
+
13
+ __all__ = [
14
+ "RedisAdapterError",
15
+ "RedisClientMissingError",
16
+ "RedisConfigError",
17
+ "RedisConnectionError",
18
+ "RedisDataAdapter",
19
+ "RedisDataFactory",
20
+ ]
@@ -0,0 +1,419 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import json
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from collections.abc import Callable
9
+ from dataclasses import asdict
10
+ from typing import Any, Mapping
11
+ from urllib.parse import urlsplit
12
+
13
+ from muscles_data.config import DataResourceConfig
14
+ from muscles_data.errors import DataConfigurationError, DataConnectionError, DataError
15
+ from muscles_data.models import DataCapability, HealthResult, InspectResult, LockHandle, StreamReadResult, WriteResult
16
+
17
+
18
+ _CLIENT_UNSET = object()
19
+ _ALLOWED_OPTIONS = {
20
+ "url",
21
+ "url_env",
22
+ "namespace",
23
+ "decode_responses",
24
+ "timeout",
25
+ "socket_timeout",
26
+ "socket_connect_timeout",
27
+ "stream_group",
28
+ "consumer",
29
+ "native_client",
30
+ }
31
+ _LOCK_RELEASE_SCRIPT = """
32
+ local token = redis.call('get', KEYS[1])
33
+ if not token or token ~= ARGV[1] then
34
+ return 0
35
+ end
36
+ redis.call('del', KEYS[1])
37
+ return 1
38
+ """
39
+
40
+
41
+ class RedisAdapterError(DataError):
42
+ """Base error for Redis data adapter failures."""
43
+
44
+
45
+ class RedisConfigError(ValueError, DataConfigurationError, RedisAdapterError):
46
+ """Raised when a Redis resource config cannot be mapped safely."""
47
+
48
+
49
+ class RedisClientMissingError(DataConfigurationError, RedisAdapterError):
50
+ """Raised when Redis adapter is used without an available client."""
51
+
52
+
53
+ class RedisConnectionError(DataConnectionError, RedisAdapterError):
54
+ """Raised when a Redis operation cannot reach or use the backend."""
55
+
56
+
57
+ class RedisDataAdapter:
58
+ resource_type = "redis"
59
+
60
+ def __init__(
61
+ self,
62
+ config: DataResourceConfig,
63
+ *,
64
+ client_factory: Callable[[DataResourceConfig], Any] | None = None,
65
+ ) -> None:
66
+ self.config = config
67
+ self._client_factory = client_factory
68
+ self._client: Any = _CLIENT_UNSET
69
+ self._lock = threading.RLock()
70
+ self.closed = False
71
+
72
+ def get(self, key: str) -> bytes | None:
73
+ try:
74
+ value = self._client_instance().get(self._key(key))
75
+ except (RedisClientMissingError, RedisConfigError):
76
+ raise
77
+ except Exception as exc:
78
+ raise RedisConnectionError(self._safe_error(exc)) from exc
79
+ return _bytes_or_none(value)
80
+
81
+ def set(self, key: str, value: bytes, ttl_seconds: float | None = None) -> WriteResult:
82
+ kwargs: dict[str, Any] = {}
83
+ if ttl_seconds is not None:
84
+ kwargs["px"] = _ttl_ms(ttl_seconds, "Redis key ttl_seconds")
85
+ try:
86
+ written = bool(self._client_instance().set(self._key(key), bytes(value), **kwargs))
87
+ except (RedisClientMissingError, RedisConfigError):
88
+ raise
89
+ except Exception as exc:
90
+ raise RedisConnectionError(self._safe_error(exc)) from exc
91
+ return WriteResult(written=1 if written else 0, matched=1 if written else 0)
92
+
93
+ def delete(self, key: str) -> WriteResult:
94
+ try:
95
+ deleted = int(self._client_instance().delete(self._key(key)) or 0)
96
+ except (RedisClientMissingError, RedisConfigError):
97
+ raise
98
+ except Exception as exc:
99
+ raise RedisConnectionError(self._safe_error(exc)) from exc
100
+ return WriteResult(deleted=deleted, matched=deleted)
101
+
102
+ def exists(self, key: str) -> bool:
103
+ try:
104
+ return bool(self._client_instance().exists(self._key(key)))
105
+ except (RedisClientMissingError, RedisConfigError):
106
+ raise
107
+ except Exception as exc:
108
+ raise RedisConnectionError(self._safe_error(exc)) from exc
109
+
110
+ def acquire_lock(self, name: str, ttl_seconds: float):
111
+ ttl_ms = _ttl_ms(ttl_seconds, "Redis lock ttl_seconds")
112
+ token = uuid.uuid4().hex
113
+ lock_key = self._lock_key(name)
114
+ try:
115
+ acquired = bool(self._client_instance().set(lock_key, token, nx=True, px=ttl_ms))
116
+ except (RedisClientMissingError, RedisConfigError):
117
+ raise
118
+ except Exception as exc:
119
+ raise RedisConnectionError(self._safe_error(exc)) from exc
120
+ if not acquired:
121
+ return None
122
+ return LockHandle(name=str(name), token=token, expires_at=time.monotonic() + float(ttl_seconds))
123
+
124
+ def release_lock(self, handle) -> WriteResult:
125
+ if handle is None:
126
+ return WriteResult()
127
+ name = getattr(handle, "name", None)
128
+ token = getattr(handle, "token", None)
129
+ if not name or not token:
130
+ raise RedisConfigError("Redis lock handle requires name and token")
131
+ try:
132
+ deleted = int(self._client_instance().eval(_LOCK_RELEASE_SCRIPT, 1, self._lock_key(str(name)), str(token)) or 0)
133
+ except (RedisClientMissingError, RedisConfigError):
134
+ raise
135
+ except Exception as exc:
136
+ raise RedisConnectionError(self._safe_error(exc)) from exc
137
+ matched = 1 if deleted else 0
138
+ return WriteResult(deleted=matched, matched=matched)
139
+
140
+ def publish(self, stream: str, message: Mapping[str, Any]) -> WriteResult:
141
+ try:
142
+ message_id = self._client_instance().xadd(self._stream_key(stream), _stream_fields(message))
143
+ except (RedisClientMissingError, RedisConfigError):
144
+ raise
145
+ except Exception as exc:
146
+ raise RedisConnectionError(self._safe_error(exc)) from exc
147
+ return WriteResult(written=1, matched=1, message_id=_text(message_id))
148
+
149
+ def read(self, stream: str, cursor: str | None = None, limit: int = 100) -> StreamReadResult:
150
+ if limit <= 0:
151
+ return StreamReadResult(messages=[], cursor=cursor)
152
+ stream_name = str(stream)
153
+ stream_key = self._stream_key(stream_name)
154
+ try:
155
+ self._ensure_group(stream_key)
156
+ response = self._client_instance().xreadgroup(
157
+ self.stream_group(),
158
+ self.consumer_name(),
159
+ {stream_key: cursor or ">"},
160
+ count=max(0, int(limit)),
161
+ )
162
+ except (RedisClientMissingError, RedisConfigError):
163
+ raise
164
+ except Exception as exc:
165
+ raise RedisConnectionError(self._safe_error(exc)) from exc
166
+
167
+ messages: list[dict[str, Any]] = []
168
+ next_cursor = cursor
169
+ for _raw_name, entries in response or []:
170
+ for message_id, fields in entries:
171
+ normalized_id = _text(message_id)
172
+ messages.append(
173
+ {
174
+ "stream": stream_name,
175
+ "id": normalized_id,
176
+ **_decode_envelope(fields),
177
+ }
178
+ )
179
+ next_cursor = normalized_id
180
+ return StreamReadResult(messages=messages, cursor=next_cursor)
181
+
182
+ def ack(self, stream: str, message_id: str) -> WriteResult:
183
+ group = self.stream_group()
184
+ try:
185
+ matched = int(self._client_instance().xack(self._stream_key(stream), group, str(message_id)) or 0)
186
+ except (RedisClientMissingError, RedisConfigError):
187
+ raise
188
+ except Exception as exc:
189
+ raise RedisConnectionError(self._safe_error(exc)) from exc
190
+ return WriteResult(matched=matched)
191
+
192
+ def inspect(self) -> dict[str, Any]:
193
+ return asdict(
194
+ InspectResult(
195
+ name=self.config.name,
196
+ type=self.config.type,
197
+ capabilities=[],
198
+ initialized=self._client is not _CLIENT_UNSET,
199
+ status="ok",
200
+ options=self.config.safe_options(),
201
+ details={
202
+ "backend": "redis",
203
+ "namespace_enabled": bool(self.namespace()),
204
+ "stream_group": self.stream_group(),
205
+ },
206
+ )
207
+ )
208
+
209
+ def health(self) -> HealthResult:
210
+ try:
211
+ ping = bool(self._client_instance().ping())
212
+ except DataConfigurationError as exc:
213
+ return HealthResult(status="failed", code="configuration_error", message=str(exc))
214
+ except Exception as exc:
215
+ return HealthResult(status="failed", code="connection_failed", message=self._safe_error(exc))
216
+ if not ping:
217
+ return HealthResult(status="failed", code="connection_failed", message="Redis ping failed")
218
+ return HealthResult(status="ok", code="ok", message="Redis connection is available")
219
+
220
+ def close(self) -> None:
221
+ if self._client is _CLIENT_UNSET:
222
+ self.closed = True
223
+ return
224
+ close = getattr(self._client, "close", None)
225
+ if callable(close):
226
+ close()
227
+ self.closed = True
228
+
229
+ def native_client(self):
230
+ return self._client_instance()
231
+
232
+ def namespace(self) -> str:
233
+ return str(self.config.options.get("namespace", "")).strip(":")
234
+
235
+ def stream_group(self) -> str:
236
+ group = str(self.config.options.get("stream_group", "default"))
237
+ if not group:
238
+ raise RedisConfigError("Redis stream_group must not be empty")
239
+ return group
240
+
241
+ def consumer_name(self) -> str:
242
+ consumer = str(self.config.options.get("consumer", "muscles-worker"))
243
+ if not consumer:
244
+ raise RedisConfigError("Redis consumer must not be empty")
245
+ return consumer
246
+
247
+ def _ensure_group(self, stream_key: str) -> None:
248
+ create = getattr(self._client_instance(), "xgroup_create", None)
249
+ if not callable(create):
250
+ raise RedisConfigError("Redis client does not support consumer groups")
251
+ try:
252
+ create(stream_key, self.stream_group(), id="0-0", mkstream=True)
253
+ except Exception as exc:
254
+ if "busygroup" not in str(exc).lower() and "already exists" not in str(exc).lower():
255
+ raise RedisConnectionError(self._safe_error(exc)) from exc
256
+
257
+ def _key(self, key: str) -> str:
258
+ normalized = _name(key, "Redis key")
259
+ namespace = self.namespace()
260
+ return f"{namespace}:{normalized}" if namespace else normalized
261
+
262
+ def _lock_key(self, name: str) -> str:
263
+ return self._key(f"lock:{_name(name, 'Redis lock name')}")
264
+
265
+ def _stream_key(self, stream: str) -> str:
266
+ return self._key(f"stream:{_name(stream, 'Redis stream name')}")
267
+
268
+ def _client_instance(self):
269
+ if self._client is _CLIENT_UNSET:
270
+ with self._lock:
271
+ if self._client is _CLIENT_UNSET:
272
+ self._validate_options()
273
+ client = (
274
+ self._client_factory(self.config)
275
+ if self._client_factory
276
+ else _default_redis_client(self.config)
277
+ )
278
+ if client is None:
279
+ raise RedisClientMissingError("Redis client is not available")
280
+ self._client = client
281
+ return self._client
282
+
283
+ def _validate_options(self) -> None:
284
+ unknown = sorted(set(self.config.options) - _ALLOWED_OPTIONS)
285
+ if unknown:
286
+ names = ", ".join(unknown)
287
+ raise RedisConfigError(f"Unsupported Redis resource options: {names}")
288
+ if not self.config.options.get("url") and not self.config.options.get("url_env"):
289
+ raise RedisConfigError("Redis resource requires url_env")
290
+ for option in ("timeout", "socket_timeout", "socket_connect_timeout"):
291
+ if option in self.config.options and float(self.config.options[option]) <= 0:
292
+ raise RedisConfigError(f"Redis {option} must be positive")
293
+
294
+ def _safe_error(self, exc: Exception) -> str:
295
+ message = str(exc)
296
+ try:
297
+ url = str(self.config.resolved_options().get("url", ""))
298
+ except DataConfigurationError:
299
+ url = str(self.config.options.get("url", ""))
300
+ sensitive_values = {url}
301
+ try:
302
+ parsed = urlsplit(url)
303
+ if parsed.username:
304
+ sensitive_values.add(parsed.username)
305
+ if parsed.password:
306
+ sensitive_values.add(parsed.password)
307
+ except Exception: # pragma: no cover
308
+ pass
309
+ for value in sorted((item for item in sensitive_values if item), key=len, reverse=True):
310
+ message = message.replace(value, "***")
311
+ return message
312
+
313
+
314
+ class RedisDataFactory:
315
+ resource_type = "redis"
316
+
317
+ def __init__(self, *, client_factory: Callable[[DataResourceConfig], Any] | None = None) -> None:
318
+ self._client_factory = client_factory
319
+
320
+ def capabilities(self, config: DataResourceConfig) -> set[DataCapability]:
321
+ native = {DataCapability.NATIVE_CLIENT} if bool(config.options.get("native_client")) else set()
322
+ return {
323
+ DataCapability.KEY_VALUE,
324
+ DataCapability.CACHE,
325
+ DataCapability.LOCK,
326
+ DataCapability.STREAM,
327
+ DataCapability.HEALTHCHECK,
328
+ } | native
329
+
330
+ def create(self, config: DataResourceConfig) -> RedisDataAdapter:
331
+ return RedisDataAdapter(config, client_factory=self._client_factory)
332
+
333
+
334
+ def _default_redis_client(config: DataResourceConfig):
335
+ try:
336
+ redis_module = importlib.import_module("redis")
337
+ except ImportError as exc:
338
+ raise RedisClientMissingError("redis package is not installed; install muscles-data-redis") from exc
339
+ redis_cls = getattr(redis_module, "Redis", None)
340
+ from_url = getattr(redis_cls, "from_url", None) if redis_cls is not None else None
341
+ if from_url is None:
342
+ from_url = getattr(redis_module, "from_url", None)
343
+ if from_url is None:
344
+ raise RedisClientMissingError("redis package does not expose Redis.from_url")
345
+
346
+ options = config.resolved_options()
347
+ kwargs: dict[str, Any] = {}
348
+ kwargs["decode_responses"] = bool(options.get("decode_responses", False))
349
+ timeout = options.get("timeout")
350
+ if timeout is not None:
351
+ kwargs["socket_timeout"] = float(timeout)
352
+ kwargs["socket_connect_timeout"] = float(timeout)
353
+ for option in ("socket_timeout", "socket_connect_timeout"):
354
+ if option in options:
355
+ kwargs[option] = float(options[option])
356
+ return from_url(str(options["url"]), **kwargs)
357
+
358
+
359
+ def _ttl_ms(value: float, label: str) -> int:
360
+ seconds = float(value)
361
+ if seconds <= 0:
362
+ raise RedisConfigError(f"{label} must be positive")
363
+ return max(1, int(seconds * 1000))
364
+
365
+
366
+ def _name(value: str, label: str) -> str:
367
+ normalized = str(value).strip()
368
+ if not normalized:
369
+ raise RedisConfigError(f"{label} must not be empty")
370
+ return normalized.strip(":")
371
+
372
+
373
+ def _bytes_or_none(value: Any) -> bytes | None:
374
+ if value is None:
375
+ return None
376
+ if isinstance(value, bytes):
377
+ return value
378
+ if isinstance(value, bytearray):
379
+ return bytes(value)
380
+ if isinstance(value, memoryview):
381
+ return value.tobytes()
382
+ return str(value).encode("utf-8")
383
+
384
+
385
+ def _text(value: Any) -> str:
386
+ if isinstance(value, bytes):
387
+ return value.decode("utf-8")
388
+ return str(value)
389
+
390
+
391
+ def _stream_fields(message: Mapping[str, Any]) -> dict[str, Any]:
392
+ return {
393
+ "_schema": "muscles.data.message.v1",
394
+ "_payload": json.dumps(dict(message), ensure_ascii=False, sort_keys=True),
395
+ }
396
+
397
+
398
+ def _read_fields(fields: Mapping[Any, Any]) -> dict[str, Any]:
399
+ return {_text(key): _decode_stream_value(value) for key, value in dict(fields).items()}
400
+
401
+
402
+ def _decode_stream_value(value: Any) -> Any:
403
+ if isinstance(value, bytes):
404
+ return value.decode("utf-8")
405
+ return value
406
+
407
+
408
+ def _decode_envelope(fields: Mapping[Any, Any]) -> dict[str, Any]:
409
+ normalized = _read_fields(fields)
410
+ try:
411
+ payload = json.loads(str(normalized.get("_payload", "{}")))
412
+ except (TypeError, ValueError) as exc:
413
+ raise RedisConnectionError("Redis stream message has invalid envelope payload") from exc
414
+ if not isinstance(payload, dict):
415
+ raise RedisConnectionError("Redis stream message envelope payload must be an object")
416
+ return {
417
+ "fields": payload,
418
+ "envelope": {"version": 1, "schema": normalized.get("_schema")},
419
+ }
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-redis
3
+ Version: 0.1.0
4
+ Summary: Redis KeyValuePort, LockPort and StreamPort adapter for muscles-data
5
+ Author-email: "Denis B." <denis@butko.info>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: muscles-data<1.0.0,>=0.1.0
9
+ Requires-Dist: redis<9.0,>=5.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-redis
14
+
15
+ Redis adapter package for `muscles-data`.
16
+
17
+ This package is intentionally separate from `muscles-data`: the core package
18
+ owns typed ports, resource runtime and diagnostics, while this package owns
19
+ Redis-backed `KeyValuePort`, `LockPort` and `StreamPort` implementations.
20
+
21
+ ## Related packages
22
+
23
+ - Core runtime and port contracts:
24
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
25
+ - Elasticsearch search adapter:
26
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
27
+ - OpenSearch search adapter:
28
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
29
+ - Qdrant vector adapter:
30
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
31
+ - MongoDB document-store adapter:
32
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
33
+ - S3 object-store adapter:
34
+ [`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
35
+ - SQLAlchemy direct SQL resource adapter:
36
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
37
+ - Executable example:
38
+ [`example_data_redis_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_redis_1)
39
+
40
+ ## Usage
41
+
42
+ Register the factory in the project composition root:
43
+
44
+ ```python
45
+ from muscles_data.catalog import DataAdapterCatalog
46
+ from muscles_data.ports import KeyValuePort
47
+ from muscles_data.runtime import DataRuntime
48
+ from muscles_data_redis import RedisDataFactory
49
+
50
+ catalog = DataAdapterCatalog.with_defaults()
51
+ catalog.register(RedisDataFactory())
52
+
53
+ runtime = DataRuntime(config=config, catalog=catalog)
54
+ cache = runtime.require_port("cache.redis", KeyValuePort)
55
+ ```
56
+
57
+ Resource config stays in the project:
58
+
59
+ ```yaml
60
+ data:
61
+ resources:
62
+ cache.redis:
63
+ type: redis
64
+ url_env: REDIS_URL
65
+ namespace: app
66
+ timeout: 3
67
+ stream_group: workers
68
+ consumer: index-worker
69
+ ```
70
+
71
+ The adapter creates the Redis client lazily by key-value, lock, stream, explicit
72
+ native access or `data.doctor` operations. Application code should use
73
+ `KeyValuePort`, `LockPort` and `StreamPort`; direct client access is only an
74
+ advanced escape hatch with `native_client: true`.
75
+
76
+ Streams use `XADD`, `XREADGROUP` and `XACK`. The configured consumer group is
77
+ created on first read and messages use the versioned
78
+ `muscles.data.message.v1` envelope. Retry and dead-letter decisions stay with
79
+ the application worker.
80
+
81
+ See `muscular-example/example_data_redis_1` for an executable example.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/muscles_data_redis/__init__.py
4
+ src/muscles_data_redis/adapter.py
5
+ src/muscles_data_redis.egg-info/PKG-INFO
6
+ src/muscles_data_redis.egg-info/SOURCES.txt
7
+ src/muscles_data_redis.egg-info/dependency_links.txt
8
+ src/muscles_data_redis.egg-info/requires.txt
9
+ src/muscles_data_redis.egg-info/top_level.txt
10
+ tests/test_integration.py
11
+ tests/test_redis_adapter.py
@@ -0,0 +1,5 @@
1
+ muscles-data<1.0.0,>=0.1.0
2
+ redis<9.0,>=5.0
3
+
4
+ [dev]
5
+ pytest<9.0,>=8.0
@@ -0,0 +1 @@
1
+ muscles_data_redis
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from uuid import uuid4
6
+
7
+ import pytest
8
+ from muscles_data.catalog import DataAdapterCatalog
9
+ from muscles_data.config import DataConfig
10
+ from muscles_data.ports import KeyValuePort, LockPort, StreamPort
11
+ from muscles_data.runtime import DataRuntime
12
+
13
+ from muscles_data_redis import RedisDataFactory
14
+
15
+
16
+ pytestmark = [
17
+ pytest.mark.integration,
18
+ pytest.mark.skipif(not os.getenv("MUSCLES_DATA_INTEGRATION"), reason="backend integration is disabled"),
19
+ ]
20
+
21
+
22
+ def test_redis_real_kv_lock_stream_lifecycle():
23
+ namespace = f"muscles-data-it-{uuid4().hex[:12]}"
24
+ config = DataConfig.from_raw(
25
+ {
26
+ "data": {
27
+ "resources": {
28
+ "cache.redis": {
29
+ "type": "redis",
30
+ "url_env": "REDIS_URL",
31
+ "namespace": namespace,
32
+ "stream_group": f"group-{namespace}",
33
+ "consumer": f"consumer-{namespace}",
34
+ }
35
+ }
36
+ }
37
+ }
38
+ )
39
+ catalog = DataAdapterCatalog.with_defaults()
40
+ catalog.register(RedisDataFactory())
41
+ runtime = DataRuntime(config=config, catalog=catalog)
42
+
43
+ try:
44
+ key_value = runtime.require_port("cache.redis", KeyValuePort)
45
+ lock = runtime.require_port("cache.redis", LockPort)
46
+ stream = runtime.require_port("cache.redis", StreamPort)
47
+ contracts = pytest.importorskip("muscles_data.contracts")
48
+ for name, port in (
49
+ ("assert_key_value_contract", key_value),
50
+ ("assert_lock_contract", lock),
51
+ ("assert_stream_contract", stream),
52
+ ):
53
+ contract = getattr(contracts, name, None)
54
+ if contract is not None:
55
+ contract(lambda port=port: port)
56
+ assert key_value.set("ttl", b"short", ttl_seconds=0.1).written == 1
57
+ time.sleep(0.2)
58
+ assert key_value.exists("ttl") is False
59
+ assert runtime.doctor()["status"] == "ok"
60
+ finally:
61
+ runtime.close()
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import pytest
6
+ from muscles_data.catalog import DataAdapterCatalog
7
+ from muscles_data.config import DataConfig
8
+ from muscles_data.models import DataCapability, LockHandle
9
+ from muscles_data.ports import KeyValuePort, LockPort, StreamPort
10
+ from muscles_data.runtime import DataRuntime
11
+
12
+ from muscles_data_redis import RedisClientMissingError, RedisConfigError, RedisConnectionError, RedisDataFactory
13
+
14
+
15
+ class FakeRedisClient:
16
+ def __init__(self, *, fail_ping: bool = False) -> None:
17
+ self.fail_ping = fail_ping
18
+ self.values: dict[str, Any] = {}
19
+ self.sets: list[dict[str, Any]] = []
20
+ self.deletes: list[tuple[str, ...]] = []
21
+ self.streams: dict[str, list[tuple[str, dict[str, Any]]]] = {}
22
+ self.xacks: list[dict[str, Any]] = []
23
+ self.xgroups: list[dict[str, Any]] = []
24
+ self.pings = 0
25
+ self.closed = False
26
+
27
+ def set(self, name: str, value, **kwargs):
28
+ self.sets.append({"name": name, "value": value, **kwargs})
29
+ if kwargs.get("nx") and name in self.values:
30
+ return False
31
+ self.values[name] = value
32
+ return True
33
+
34
+ def get(self, name: str):
35
+ return self.values.get(name)
36
+
37
+ def delete(self, *names: str) -> int:
38
+ self.deletes.append(tuple(names))
39
+ deleted = 0
40
+ for name in names:
41
+ deleted += 1 if self.values.pop(name, None) is not None else 0
42
+ return deleted
43
+
44
+ def exists(self, *names: str) -> int:
45
+ return sum(1 for name in names if name in self.values)
46
+
47
+ def eval(self, _script: str, _numkeys: int, key: str, expected_token: str):
48
+ if self.values.get(key) != expected_token:
49
+ return 0
50
+ self.values.pop(key, None)
51
+ return 1
52
+
53
+ def xadd(self, name: str, fields: dict[str, Any]):
54
+ stream = self.streams.setdefault(name, [])
55
+ message_id = f"{len(stream) + 1}-0"
56
+ stream.append((message_id, dict(fields)))
57
+ return message_id
58
+
59
+ def xread(self, streams: dict[str, str], count: int | None = None, block: int | None = None):
60
+ del block
61
+ output = []
62
+ for name, cursor in streams.items():
63
+ messages = [
64
+ (message_id, fields)
65
+ for message_id, fields in self.streams.get(name, [])
66
+ if cursor in {"0", "0-0", ">"} or message_id > cursor
67
+ ]
68
+ output.append((name, messages[:count]))
69
+ return output
70
+
71
+ def xgroup_create(self, name: str, groupname: str, id: str = "0-0", mkstream: bool = False):
72
+ self.xgroups.append({"name": name, "groupname": groupname, "id": id, "mkstream": mkstream})
73
+
74
+ def xreadgroup(self, groupname: str, consumername: str, streams: dict[str, str], count: int | None = None, block: int | None = None):
75
+ del groupname, consumername, block
76
+ return self.xread(streams, count=count)
77
+
78
+ def xack(self, name: str, groupname: str, *ids: str) -> int:
79
+ self.xacks.append({"name": name, "groupname": groupname, "ids": ids})
80
+ known = {message_id for message_id, _fields in self.streams.get(name, [])}
81
+ return sum(1 for message_id in ids if message_id in known)
82
+
83
+ def ping(self) -> bool:
84
+ self.pings += 1
85
+ if self.fail_ping:
86
+ raise TimeoutError("redis password=redis-secret timed out")
87
+ return True
88
+
89
+ def close(self) -> None:
90
+ self.closed = True
91
+
92
+
93
+ def _config() -> dict:
94
+ return {
95
+ "data": {
96
+ "resources": {
97
+ "cache.redis": {
98
+ "type": "redis",
99
+ "url": "redis://:redis-secret@localhost:6379/0",
100
+ "namespace": "app",
101
+ "stream_group": "workers",
102
+ "native_client": True,
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+
109
+ def _runtime(client: FakeRedisClient | None):
110
+ catalog = DataAdapterCatalog.with_defaults()
111
+ catalog.register(RedisDataFactory(client_factory=lambda _config: client))
112
+ return DataRuntime(config=DataConfig.from_raw(_config()), catalog=catalog)
113
+
114
+
115
+ def test_redis_external_adapter_maps_key_value_lock_stream_and_native_access():
116
+ client = FakeRedisClient()
117
+ runtime = _runtime(client)
118
+
119
+ listed = runtime.list_resources()[0]
120
+ assert listed["type"] == "redis"
121
+ assert {"key_value", "cache", "lock", "stream"} <= set(listed["capabilities"])
122
+ assert listed["initialized"] is False
123
+
124
+ cache = runtime.require_port("cache.redis", KeyValuePort)
125
+ assert cache.set("cursor", b"cursor-1", ttl_seconds=2.5).written == 1
126
+ assert client.sets[0]["name"] == "app:cursor"
127
+ assert client.sets[0]["px"] == 2500
128
+ assert cache.get("cursor") == b"cursor-1"
129
+ assert cache.exists("cursor") is True
130
+
131
+ lock = runtime.require_port("cache.redis", LockPort)
132
+ handle = lock.acquire_lock("daily-job", ttl_seconds=5)
133
+ assert isinstance(handle, LockHandle)
134
+ assert lock.release_lock(handle).deleted == 1
135
+
136
+ stream = runtime.require_port("cache.redis", StreamPort)
137
+ assert stream.publish("events", {"kind": "created"}).written == 1
138
+ read = stream.read("events", limit=10)
139
+ assert read.messages[0]["fields"] == {"kind": "created"}
140
+ assert read.messages[0]["envelope"]["version"] == 1
141
+ assert stream.ack("events", "1-0").matched == 1
142
+ assert client.xgroups[0]["groupname"] == "workers"
143
+
144
+ native = runtime.require_resource("cache.redis", DataCapability.NATIVE_CLIENT).native_client()
145
+ assert native is client
146
+ assert runtime.doctor()["status"] == "ok"
147
+ assert client.pings == 1
148
+ assert runtime.close()["status"] == "ok"
149
+ assert client.closed is True
150
+
151
+
152
+ def test_redis_external_adapter_reports_safe_failures():
153
+ with pytest.raises(RedisClientMissingError):
154
+ _runtime(None).require_port("cache.redis", KeyValuePort).get("cursor")
155
+
156
+ failing = _runtime(FakeRedisClient(fail_ping=True)).doctor()
157
+ assert failing["status"] == "failed"
158
+ assert "redis-secret" not in repr(failing)
159
+
160
+ bad_client = FakeRedisClient()
161
+ bad_client.get = lambda _name: (_ for _ in ()).throw(RuntimeError("redis://:redis-secret@localhost unavailable"))
162
+ with pytest.raises(RedisConnectionError):
163
+ _runtime(bad_client).require_port("cache.redis", KeyValuePort).get("cursor")
164
+
165
+ catalog = DataAdapterCatalog.with_defaults()
166
+ catalog.register(RedisDataFactory(client_factory=lambda config: config.resolved_options()))
167
+ unsupported = DataRuntime(
168
+ config=DataConfig.from_raw({"data": {"resources": {"cache.redis": {"type": "redis", "url": "redis://localhost", "unsafe": True}}}}),
169
+ catalog=catalog,
170
+ )
171
+ with pytest.raises(RedisConfigError, match="Unsupported Redis resource options"):
172
+ unsupported.require_port("cache.redis", KeyValuePort).get("cursor")
173
+
174
+
175
+ def test_redis_stream_uses_consumer_groups_and_resolves_url_env(monkeypatch):
176
+ monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0")
177
+ client = FakeRedisClient()
178
+ captured: list[dict] = []
179
+ catalog = DataAdapterCatalog.with_defaults()
180
+ catalog.register(
181
+ RedisDataFactory(client_factory=lambda config: captured.append(config.resolved_options()) or client)
182
+ )
183
+ runtime = DataRuntime(
184
+ config=DataConfig.from_raw(
185
+ {
186
+ "data": {
187
+ "resources": {
188
+ "stream.redis": {
189
+ "type": "redis",
190
+ "url_env": "REDIS_URL",
191
+ "namespace": "assetforge",
192
+ "stream_group": "workers",
193
+ "consumer": "worker-1",
194
+ }
195
+ }
196
+ }
197
+ }
198
+ ),
199
+ catalog=catalog,
200
+ )
201
+
202
+ stream = runtime.require_port("stream.redis", StreamPort)
203
+ stream.publish("index", {"job": "reindex"})
204
+ stream.read("index")
205
+
206
+ assert captured[0]["url"] == "redis://localhost:6379/0"
207
+ assert client.xgroups[0]["mkstream"] is True
208
+
209
+
210
+ def test_redis_doctor_reports_configuration_error_for_missing_url_env():
211
+ catalog = DataAdapterCatalog.with_defaults()
212
+ catalog.register(RedisDataFactory(client_factory=lambda config: config.resolved_options()))
213
+ runtime = DataRuntime(
214
+ config=DataConfig.from_raw(
215
+ {"data": {"resources": {"stream.redis": {"type": "redis", "url_env": "MISSING_REDIS_URL"}}}}
216
+ ),
217
+ catalog=catalog,
218
+ )
219
+
220
+ doctor = runtime.doctor()
221
+ check = doctor["checks"][0]
222
+ assert check["status"] == "failed"
223
+ assert check["code"] == "configuration_error"