codex-platform 0.1.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.
Files changed (45) hide show
  1. codex_platform/__init__.py +1 -0
  2. codex_platform/notifications/__init__.py +48 -0
  3. codex_platform/notifications/channels.py +24 -0
  4. codex_platform/notifications/clients/__init__.py +3 -0
  5. codex_platform/notifications/clients/smtp.py +134 -0
  6. codex_platform/notifications/delivery/__init__.py +18 -0
  7. codex_platform/notifications/delivery/arq.py +69 -0
  8. codex_platform/notifications/delivery/base.py +39 -0
  9. codex_platform/notifications/delivery/direct.py +89 -0
  10. codex_platform/notifications/dto.py +63 -0
  11. codex_platform/notifications/interfaces.py +30 -0
  12. codex_platform/notifications/orchestrator.py +116 -0
  13. codex_platform/notifications/registry.py +71 -0
  14. codex_platform/notifications/renderer.py +111 -0
  15. codex_platform/redis_service/__init__.py +59 -0
  16. codex_platform/redis_service/base.py +69 -0
  17. codex_platform/redis_service/exceptions.py +20 -0
  18. codex_platform/redis_service/keys.py +110 -0
  19. codex_platform/redis_service/managers/__init__.py +18 -0
  20. codex_platform/redis_service/managers/base_manager.py +31 -0
  21. codex_platform/redis_service/operations/__init__.py +19 -0
  22. codex_platform/redis_service/operations/hash.py +302 -0
  23. codex_platform/redis_service/operations/json_module.py +129 -0
  24. codex_platform/redis_service/operations/json_string.py +113 -0
  25. codex_platform/redis_service/operations/list_.py +219 -0
  26. codex_platform/redis_service/operations/pipeline.py +197 -0
  27. codex_platform/redis_service/operations/set_.py +212 -0
  28. codex_platform/redis_service/operations/string.py +318 -0
  29. codex_platform/redis_service/operations/zset.py +277 -0
  30. codex_platform/redis_service/service.py +80 -0
  31. codex_platform/streams/__init__.py +59 -0
  32. codex_platform/streams/consumer.py +106 -0
  33. codex_platform/streams/dispatcher.py +175 -0
  34. codex_platform/streams/processor.py +217 -0
  35. codex_platform/streams/producer.py +60 -0
  36. codex_platform/streams/router.py +78 -0
  37. codex_platform/workers/__init__.py +1 -0
  38. codex_platform/workers/arq/__init__.py +27 -0
  39. codex_platform/workers/arq/base.py +170 -0
  40. codex_platform/workers/arq/config.py +67 -0
  41. codex_platform/workers/arq/task_utils.py +72 -0
  42. codex_platform/workers/arq/types.py +11 -0
  43. codex_platform-0.1.0.dist-info/METADATA +120 -0
  44. codex_platform-0.1.0.dist-info/RECORD +45 -0
  45. codex_platform-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,59 @@
1
+ """
2
+ codex_platform.streams
3
+ =============================
4
+ Redis Streams broker — event sourcing / pub-sub layer.
5
+
6
+ Отдельный модуль от redis_service.
7
+ Stream — это брокер сообщений, а не структура данных.
8
+
9
+ Components:
10
+ - ``StreamProducer`` — XADD (publish events)
11
+ - ``StreamConsumer`` — XREADGROUP + XACK (low-level read)
12
+ - ``StreamProcessor`` — background polling engine (wraps StreamConsumer)
13
+ - ``StreamRouter`` — groups handlers by event type (per-feature)
14
+ - ``StreamDispatcher`` — routes messages to handlers (generic, no DI)
15
+
16
+ Typical setup::
17
+
18
+ from codex_platform.streams import (
19
+ StreamConsumer, StreamProducer,
20
+ StreamProcessor, StreamRouter, StreamDispatcher,
21
+ )
22
+
23
+ # Producer
24
+ producer = StreamProducer(redis_client, stream_name="events:orders")
25
+
26
+ # Consumer + Processor
27
+ consumer = StreamConsumer(redis_client, "events:orders", "workers", "worker_1")
28
+ dispatcher = StreamDispatcher()
29
+
30
+ @dispatcher.on("order.paid")
31
+ async def handle_order(payload: dict) -> None:
32
+ ...
33
+
34
+ processor = StreamProcessor(
35
+ storage=consumer,
36
+ stream_name="events:orders",
37
+ consumer_group_name="workers",
38
+ consumer_name="worker_1",
39
+ )
40
+ processor.set_callback(dispatcher.process)
41
+ await processor.start()
42
+ """
43
+
44
+ from .consumer import StreamConsumer, StreamEvent
45
+ from .dispatcher import RetrySchedulerProtocol, StreamDispatcher
46
+ from .processor import StreamProcessor, StreamStorageProtocol
47
+ from .producer import StreamProducer
48
+ from .router import StreamRouter
49
+
50
+ __all__ = [
51
+ "StreamProducer",
52
+ "StreamConsumer",
53
+ "StreamEvent",
54
+ "StreamProcessor",
55
+ "StreamStorageProtocol",
56
+ "StreamRouter",
57
+ "StreamDispatcher",
58
+ "RetrySchedulerProtocol",
59
+ ]
@@ -0,0 +1,106 @@
1
+ """
2
+ codex_platform.streams.consumer
3
+ =======================================
4
+ Redis Stream consumer — reads events via XREADGROUP, acknowledges via XACK.
5
+ """
6
+
7
+ import logging
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from redis.asyncio import Redis
12
+ from redis.exceptions import ConnectionError, RedisError, TimeoutError
13
+
14
+ from codex_platform.redis_service.exceptions import RedisConnectionError, RedisServiceError
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class StreamEvent:
21
+ """Parsed Redis Stream event."""
22
+
23
+ id: str
24
+ event_type: str
25
+ data: dict[str, str]
26
+
27
+
28
+ class StreamConsumer:
29
+ """Reads events from a Redis Stream via a consumer group (XREADGROUP).
30
+
31
+ Acknowledges processed messages via XACK.
32
+ """
33
+
34
+ def __init__(self, client: Redis, stream_name: str, group: str, consumer: str) -> None:
35
+ self.client = client
36
+ self.stream_name = stream_name
37
+ self.group = group
38
+ self.consumer = consumer
39
+
40
+ async def ensure_group(self) -> None:
41
+ """Create the consumer group if it does not already exist (XGROUP CREATE … MKSTREAM)."""
42
+ try:
43
+ await self.client.xgroup_create(self.stream_name, self.group, id="0", mkstream=True)
44
+ log.info("StreamConsumer | group created stream='%s' group='%s'", self.stream_name, self.group)
45
+ except RedisError as e:
46
+ if "BUSYGROUP" in str(e):
47
+ log.debug("StreamConsumer | group already exists stream='%s' group='%s'", self.stream_name, self.group)
48
+ else:
49
+ raise RedisServiceError(f"Failed to create group: {e}") from e
50
+
51
+ async def read(self, count: int = 10) -> list[StreamEvent]:
52
+ """Read new events from the stream for the consumer group (XREADGROUP).
53
+
54
+ Args:
55
+ count: Maximum number of messages to fetch per call. Defaults to ``10``.
56
+
57
+ Returns:
58
+ List of :class:`StreamEvent` instances. Empty list if no new messages.
59
+
60
+ Raises:
61
+ RedisConnectionError: Redis connection failure.
62
+ RedisServiceError: Redis operation failure.
63
+ """
64
+ try:
65
+ raw = await self.client.xreadgroup(
66
+ groupname=self.group,
67
+ consumername=self.consumer,
68
+ streams={self.stream_name: ">"},
69
+ count=count,
70
+ )
71
+ if not raw:
72
+ return []
73
+ events = [self._parse(msg_id, fields) for msg_id, fields in raw[0][1]]
74
+ log.debug("StreamConsumer | read count=%d stream='%s'", len(events), self.stream_name)
75
+ return events
76
+ except (ConnectionError, TimeoutError) as e:
77
+ raise RedisConnectionError(f"Stream consumer connection failed: {e}") from e
78
+ except RedisError as e:
79
+ raise RedisServiceError(f"Stream consumer error: {e}") from e
80
+
81
+ async def ack(self, event_id: str) -> None:
82
+ """Acknowledge that an event has been processed (XACK).
83
+
84
+ Args:
85
+ event_id: Stream entry ID returned by :meth:`read`.
86
+
87
+ Raises:
88
+ RedisConnectionError: Redis connection failure.
89
+ RedisServiceError: Redis operation failure.
90
+ """
91
+ try:
92
+ await self.client.xack(self.stream_name, self.group, event_id)
93
+ log.debug("StreamConsumer | ack id='%s' stream='%s'", event_id, self.stream_name)
94
+ except (ConnectionError, TimeoutError) as e:
95
+ raise RedisConnectionError(f"Stream ack connection failed: {e}") from e
96
+ except RedisError as e:
97
+ raise RedisServiceError(f"Stream ack error: {e}") from e
98
+
99
+ @staticmethod
100
+ def _parse(msg_id: Any, fields: dict[bytes | str, bytes | str]) -> StreamEvent:
101
+ data = {
102
+ (k.decode() if isinstance(k, bytes) else k): (v.decode() if isinstance(v, bytes) else v)
103
+ for k, v in fields.items()
104
+ }
105
+ event_type = data.pop("type", "unknown")
106
+ return StreamEvent(id=str(msg_id), event_type=event_type, data=data)
@@ -0,0 +1,175 @@
1
+ """
2
+ codex_platform.streams.dispatcher
3
+ ==========================================
4
+ Generic Stream event dispatcher — routes messages to registered handlers.
5
+
6
+ Framework-agnostic: no DI container, no bot references.
7
+ For framework-specific dispatchers (e.g. with a DI container) — extend this class.
8
+
9
+ Usage::
10
+
11
+ from codex_platform.streams.dispatcher import StreamDispatcher
12
+ from codex_platform.streams.router import StreamRouter
13
+
14
+ dispatcher = StreamDispatcher()
15
+
16
+ # Register handlers directly:
17
+ @dispatcher.on("booking.confirmed")
18
+ async def handle_booking(payload: dict) -> None:
19
+ ...
20
+
21
+ # Or include a router from a feature module:
22
+ dispatcher.include_router(notifications_router)
23
+
24
+ # Connect to StreamProcessor:
25
+ processor.set_callback(dispatcher.process)
26
+ await processor.start()
27
+
28
+ Extending for framework-specific DI::
29
+
30
+ class BotDispatcher(StreamDispatcher):
31
+ def __init__(self, container):
32
+ super().__init__()
33
+ self.container = container
34
+
35
+ async def process(self, payload: dict) -> None:
36
+ # inject container into handlers, etc.
37
+ ...
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ from collections.abc import Callable
44
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
45
+
46
+ if TYPE_CHECKING:
47
+ from .router import FilterFunc, HandlerFunc, StreamRouter
48
+
49
+ log = logging.getLogger(__name__)
50
+
51
+
52
+ @runtime_checkable
53
+ class RetrySchedulerProtocol(Protocol):
54
+ """Protocol for a retry scheduler (ARQ, Celery, etc.).
55
+
56
+ Pass to ``StreamDispatcher`` for automatic rescheduling of failed messages.
57
+ """
58
+
59
+ async def schedule_retry(
60
+ self,
61
+ stream_name: str,
62
+ payload: dict[str, Any],
63
+ delay: int = 60,
64
+ ) -> None:
65
+ """Schedules message reprocessing after a delay.
66
+
67
+ Args:
68
+ stream_name: Redis Stream name.
69
+ payload: Original message data.
70
+ delay: Retry delay in seconds.
71
+ """
72
+ ...
73
+
74
+
75
+ class StreamDispatcher:
76
+ """Routes Redis Stream messages to registered handlers by event type.
77
+
78
+ Handlers are registered via ``@dispatcher.on(event_type)`` decorator
79
+ or by including ``StreamRouter`` instances.
80
+
81
+ On handler failure: if a ``retry_scheduler`` is provided, the message
82
+ is scheduled for retry. Otherwise the exception is re-raised (message
83
+ stays in PEL, unacknowledged).
84
+
85
+ Args:
86
+ retry_scheduler: Optional retry scheduler implementing ``RetrySchedulerProtocol``.
87
+
88
+ Example::
89
+
90
+ dispatcher = StreamDispatcher()
91
+
92
+ @dispatcher.on("user.registered")
93
+ async def welcome(payload: dict) -> None:
94
+ await send_welcome_email(payload["email"])
95
+
96
+ processor.set_callback(dispatcher.process)
97
+ """
98
+
99
+ def __init__(self, retry_scheduler: RetrySchedulerProtocol | None = None) -> None:
100
+ self._retry_scheduler = retry_scheduler
101
+ self._handlers: dict[str, list[tuple[HandlerFunc, FilterFunc | None]]] = {}
102
+ log.info("StreamDispatcher | initialized")
103
+
104
+ def include_router(self, router: StreamRouter) -> None:
105
+ """Merges handlers from a ``StreamRouter`` into this dispatcher.
106
+
107
+ Args:
108
+ router: Router from a feature module.
109
+ """
110
+ for event_type, handlers in router.handlers.items():
111
+ if event_type not in self._handlers:
112
+ self._handlers[event_type] = []
113
+ self._handlers[event_type].extend(handlers)
114
+ log.info("StreamDispatcher | included router types=%s", list(router.handlers.keys()))
115
+
116
+ def on(
117
+ self,
118
+ event_type: str,
119
+ filter_func: FilterFunc | None = None,
120
+ ) -> Callable[[HandlerFunc], HandlerFunc]:
121
+ """Decorator for registering a handler directly on the dispatcher.
122
+
123
+ Args:
124
+ event_type: Stream message type (e.g. ``"booking.confirmed"``).
125
+ filter_func: Optional ``payload -> bool`` filter.
126
+ """
127
+
128
+ def decorator(handler: HandlerFunc) -> HandlerFunc:
129
+ if event_type not in self._handlers:
130
+ self._handlers[event_type] = []
131
+ self._handlers[event_type].append((handler, filter_func))
132
+ return handler
133
+
134
+ return decorator
135
+
136
+ async def process(self, payload: dict[str, Any], stream_name: str = "") -> None:
137
+ """Dispatches an incoming message to matching handlers.
138
+
139
+ Called by ``StreamProcessor`` on each incoming message.
140
+
141
+ Args:
142
+ payload: Message data dict. Must contain ``"type"`` field.
143
+ stream_name: Stream name (used for retry scheduling only).
144
+ """
145
+ event_type = payload.get("type")
146
+ if not event_type:
147
+ log.warning("StreamDispatcher | message without 'type' field: %s", payload)
148
+ return
149
+
150
+ handlers = self._handlers.get(event_type, [])
151
+ if not handlers:
152
+ log.debug("StreamDispatcher | no handlers for type='%s'", event_type)
153
+ return
154
+
155
+ for handler, filter_func in handlers:
156
+ try:
157
+ if filter_func is None or filter_func(payload):
158
+ log.debug("StreamDispatcher | calling %s for type='%s'", handler.__name__, event_type)
159
+ await handler(payload)
160
+ except Exception as e:
161
+ log.error("StreamDispatcher | handler %s failed: %s", handler.__name__, e)
162
+
163
+ if self._retry_scheduler:
164
+ try:
165
+ await self._retry_scheduler.schedule_retry(
166
+ stream_name=stream_name,
167
+ payload=payload,
168
+ delay=60,
169
+ )
170
+ log.info("StreamDispatcher | retry scheduled for type='%s'", event_type)
171
+ return
172
+ except Exception as retry_err:
173
+ log.error("StreamDispatcher | retry scheduling failed: %s", retry_err)
174
+
175
+ raise
@@ -0,0 +1,217 @@
1
+ """
2
+ codex_platform.streams.processor
3
+ ========================================
4
+ Redis Stream processing engine — continuous polling with consumer group.
5
+
6
+ Implements at-least-once delivery via XACK. Generic: works with any project,
7
+ not tied to any framework (bot, Django, FastAPI, etc.).
8
+
9
+ Usage::
10
+
11
+ from codex_platform.streams import StreamConsumer
12
+ from codex_platform.streams.processor import StreamProcessor, StreamStorageProtocol
13
+
14
+ # Option 1: Use StreamConsumer directly (implements StreamStorageProtocol)
15
+ consumer = StreamConsumer(redis_client, "events:bot", "bot_group", "worker_1")
16
+ processor = StreamProcessor(storage=consumer, ...)
17
+
18
+ # Option 2: Implement StreamStorageProtocol yourself (e.g. for testing)
19
+ class FakeStorage:
20
+ async def create_group(self, stream, group): ...
21
+ async def read_events(self, stream, group, consumer, count): return []
22
+ async def ack_event(self, stream, group, message_id): ...
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import contextlib
29
+ import logging
30
+ from collections.abc import Awaitable, Callable
31
+ from typing import Any, Protocol, runtime_checkable
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ MessageCallback = Callable[[dict[str, Any]], Awaitable[None]]
36
+
37
+
38
+ @runtime_checkable
39
+ class StreamStorageProtocol(Protocol):
40
+ """Adapter protocol for Redis Stream I/O.
41
+
42
+ ``StreamConsumer`` from ``streams.consumer`` implements this protocol.
43
+ You can also implement it yourself for testing or alternative backends.
44
+ """
45
+
46
+ async def create_group(self, stream_name: str, group_name: str) -> None:
47
+ """Creates a consumer group idempotently (XGROUP CREATE ... MKSTREAM)."""
48
+ ...
49
+
50
+ async def read_events(
51
+ self,
52
+ stream_name: str,
53
+ group_name: str,
54
+ consumer_name: str,
55
+ count: int,
56
+ ) -> list[tuple[str, dict[str, Any]]]:
57
+ """Reads undelivered messages for the group (XREADGROUP ... >).
58
+
59
+ Returns:
60
+ List of (message_id, data_dict). Empty if no messages.
61
+ """
62
+ ...
63
+
64
+ async def ack_event(self, stream_name: str, group_name: str, message_id: str) -> None:
65
+ """Acknowledges message processing (XACK)."""
66
+ ...
67
+
68
+
69
+ class StreamProcessor:
70
+ """Async engine for consuming and dispatching Redis Stream events.
71
+
72
+ Runs a continuous background polling loop. On each iteration reads a batch
73
+ of messages from the consumer group, dispatches each to the registered
74
+ callback, and ACKs on success.
75
+
76
+ If the callback fails — message stays in PEL (unacknowledged) for future
77
+ recovery. The processor recovers automatically if the consumer group is lost
78
+ (NOGROUP error).
79
+
80
+ Args:
81
+ storage: Any object implementing ``StreamStorageProtocol``.
82
+ Use ``StreamConsumer`` from streams.consumer.
83
+ stream_name: Redis Stream identifier.
84
+ consumer_group_name: Shared group name (same across all instances = load balancing).
85
+ consumer_name: Unique name for this processor instance.
86
+ batch_count: Max messages per poll cycle.
87
+ poll_interval: Sleep duration (seconds) when no messages available.
88
+
89
+ TODO:
90
+ PEL recovery on startup — XPENDING/XCLAIM for messages stuck in PEL
91
+ after a processor crash.
92
+
93
+ Example::
94
+
95
+ consumer = StreamConsumer(redis, "events:bot", "bot_group", "worker_1")
96
+ processor = StreamProcessor(
97
+ storage=consumer,
98
+ stream_name="events:bot",
99
+ consumer_group_name="bot_group",
100
+ consumer_name="worker_1",
101
+ )
102
+ processor.set_callback(my_async_handler)
103
+ await processor.start()
104
+ # ... later
105
+ await processor.stop()
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ storage: StreamStorageProtocol,
111
+ stream_name: str,
112
+ consumer_group_name: str,
113
+ consumer_name: str,
114
+ batch_count: int = 10,
115
+ poll_interval: float = 1.0,
116
+ ) -> None:
117
+ self.storage = storage
118
+ self.stream_name = stream_name
119
+ self.group_name = consumer_group_name
120
+ self.consumer_name = consumer_name
121
+ self.batch_count = batch_count
122
+ self.poll_interval = poll_interval
123
+
124
+ self.is_running = False
125
+ self._callback: MessageCallback | None = None
126
+ self._task: asyncio.Task[None] | None = None
127
+
128
+ def set_callback(self, callback: MessageCallback) -> None:
129
+ """Registers the message handler.
130
+
131
+ Args:
132
+ callback: ``async def handler(payload: dict) -> None``
133
+ """
134
+ self._callback = callback
135
+
136
+ async def start(self) -> None:
137
+ """Starts the background polling loop.
138
+
139
+ Creates the consumer group (up to 5 attempts with 3s delay),
140
+ then spawns ``_consume_loop`` as an asyncio Task.
141
+ """
142
+ if self.is_running:
143
+ log.warning("StreamProcessor | already running stream='%s'", self.stream_name)
144
+ return
145
+
146
+ for attempt in range(1, 6):
147
+ try:
148
+ await self.storage.create_group(self.stream_name, self.group_name)
149
+ break
150
+ except Exception as e:
151
+ log.warning("StreamProcessor | create_group attempt %d/5: %s", attempt, e)
152
+ if attempt < 5:
153
+ await asyncio.sleep(3)
154
+ else:
155
+ log.error("StreamProcessor | failed to create group, giving up")
156
+ return
157
+
158
+ self.is_running = True
159
+ self._task = asyncio.create_task(self._consume_loop())
160
+ log.info(
161
+ "StreamProcessor | started stream='%s' group='%s' consumer='%s'",
162
+ self.stream_name,
163
+ self.group_name,
164
+ self.consumer_name,
165
+ )
166
+
167
+ async def stop(self) -> None:
168
+ """Stops the polling loop gracefully."""
169
+ self.is_running = False
170
+ if self._task and not self._task.done():
171
+ self._task.cancel()
172
+ with contextlib.suppress(asyncio.CancelledError):
173
+ await self._task
174
+ log.info("StreamProcessor | stopped stream='%s'", self.stream_name)
175
+
176
+ async def _consume_loop(self) -> None:
177
+ try:
178
+ while self.is_running:
179
+ try:
180
+ messages = await self.storage.read_events(
181
+ stream_name=self.stream_name,
182
+ group_name=self.group_name,
183
+ consumer_name=self.consumer_name,
184
+ count=self.batch_count,
185
+ )
186
+
187
+ if not messages:
188
+ await asyncio.sleep(self.poll_interval)
189
+ continue
190
+
191
+ for message_id, data in messages:
192
+ await self._process_one(message_id, data)
193
+
194
+ except asyncio.CancelledError:
195
+ raise
196
+ except Exception as e:
197
+ log.error("StreamProcessor | consume loop error: %s", e)
198
+ if "NOGROUP" in str(e):
199
+ log.warning("StreamProcessor | consumer group lost, recreating...")
200
+ with contextlib.suppress(Exception):
201
+ await self.storage.create_group(self.stream_name, self.group_name)
202
+ await asyncio.sleep(5)
203
+
204
+ except asyncio.CancelledError:
205
+ log.info("StreamProcessor | consume loop cancelled")
206
+ raise
207
+
208
+ async def _process_one(self, message_id: str, data: dict[str, Any]) -> None:
209
+ """Processes a single message. ACKs on success, leaves in PEL on failure."""
210
+ try:
211
+ if self._callback:
212
+ await self._callback(data)
213
+ await self.storage.ack_event(self.stream_name, self.group_name, message_id)
214
+ log.debug("StreamProcessor | ack id='%s'", message_id)
215
+ except Exception as e:
216
+ log.error("StreamProcessor | failed to process message id='%s': %s", message_id, e)
217
+ # Message stays in PEL (unacknowledged) for recovery
@@ -0,0 +1,60 @@
1
+ """
2
+ codex_platform.streams.producer
3
+ =======================================
4
+ Redis Stream producer — writes events via XADD.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ from redis.asyncio import Redis
11
+ from redis.exceptions import ConnectionError, RedisError, TimeoutError
12
+
13
+ from codex_platform.redis_service.exceptions import RedisConnectionError, RedisServiceError
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ class StreamProducer:
19
+ """Writes events to a Redis Stream (XADD).
20
+
21
+ Automatically sanitizes the payload before writing:
22
+ - ``bool`` values are converted to ``"True"`` / ``"False"``
23
+ - ``None`` values are filtered out
24
+ - all remaining values are coerced to ``str``
25
+ """
26
+
27
+ def __init__(self, client: Redis, stream_name: str) -> None:
28
+ self.client = client
29
+ self.stream_name = stream_name
30
+
31
+ async def add_event(self, event_type: str, data: dict[str, Any]) -> str:
32
+ """Append an event to the Redis Stream (XADD).
33
+
34
+ Args:
35
+ event_type: Event type label, e.g. ``"new_appointment"`` or ``"new_contact"``.
36
+ data: Event payload. ``None`` values are filtered out automatically.
37
+
38
+ Returns:
39
+ The ID of the newly added stream entry (e.g. ``"1718000000000-0"``).
40
+
41
+ Raises:
42
+ RedisConnectionError: Redis connection failure.
43
+ RedisServiceError: Redis operation failure.
44
+ """
45
+ payload = self._sanitize({"type": event_type, **data})
46
+ try:
47
+ result = await self.client.xadd(self.stream_name, payload)
48
+ log.info("StreamProducer | added event_type='%s' id='%s' stream='%s'", event_type, result, self.stream_name)
49
+ return str(result)
50
+ except (ConnectionError, TimeoutError) as e:
51
+ raise RedisConnectionError(f"Stream producer connection failed: {e}") from e
52
+ except RedisError as e:
53
+ raise RedisServiceError(f"Stream producer error: {e}") from e
54
+
55
+ @staticmethod
56
+ def _sanitize(data: dict[str, Any]) -> dict[str, str]:
57
+ """Convert all values to str and filter out None entries."""
58
+ return {
59
+ k: ("True" if v is True else "False" if v is False else str(v)) for k, v in data.items() if v is not None
60
+ }
@@ -0,0 +1,78 @@
1
+ """
2
+ codex_platform.streams.router
3
+ =====================================
4
+ Stream event router — groups handlers by message type.
5
+
6
+ Analogous to Aiogram/FastAPI routers: define handlers in feature modules,
7
+ then include into a dispatcher. Framework-agnostic.
8
+
9
+ Usage::
10
+
11
+ from codex_platform.streams.router import StreamRouter
12
+
13
+ router = StreamRouter()
14
+
15
+ @router.on("booking.confirmed")
16
+ async def handle_booking(payload: dict) -> None:
17
+ booking_id = payload["booking_id"]
18
+ ...
19
+
20
+ @router.on("notification.created", filter_func=lambda p: p.get("urgent"))
21
+ async def handle_urgent(payload: dict) -> None:
22
+ ...
23
+ """
24
+
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ HandlerFunc = Callable[[dict[str, Any]], Any]
29
+ FilterFunc = Callable[[dict[str, Any]], bool]
30
+
31
+
32
+ class StreamRouter:
33
+ """Groups Redis Stream event handlers by message type.
34
+
35
+ After populating, include into a ``StreamDispatcher`` via ``include_router()``.
36
+
37
+ Example::
38
+
39
+ router = StreamRouter()
40
+
41
+ @router.on("order.paid")
42
+ async def on_order_paid(payload: dict) -> None:
43
+ ...
44
+
45
+ dispatcher.include_router(router)
46
+ """
47
+
48
+ def __init__(self) -> None:
49
+ self._handlers: dict[str, list[tuple[HandlerFunc, FilterFunc | None]]] = {}
50
+
51
+ def on(
52
+ self,
53
+ event_type: str,
54
+ filter_func: FilterFunc | None = None,
55
+ ) -> Callable[[HandlerFunc], HandlerFunc]:
56
+ """Decorator for registering a handler for an event type.
57
+
58
+ Args:
59
+ event_type: Stream message type (e.g. ``"booking.confirmed"``).
60
+ filter_func: Optional ``payload -> bool`` filter. Handler is called
61
+ only when filter returns ``True``.
62
+
63
+ Returns:
64
+ Decorator (returns the original handler unchanged).
65
+ """
66
+
67
+ def decorator(handler: HandlerFunc) -> HandlerFunc:
68
+ if event_type not in self._handlers:
69
+ self._handlers[event_type] = []
70
+ self._handlers[event_type].append((handler, filter_func))
71
+ return handler
72
+
73
+ return decorator
74
+
75
+ @property
76
+ def handlers(self) -> dict[str, list[tuple[HandlerFunc, FilterFunc | None]]]:
77
+ """Registered handlers (read-only)."""
78
+ return self._handlers