deegzlibs-command-bus 2.0.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.
- command_bus/__init__.py +41 -0
- command_bus/adapters/__init__.py +30 -0
- command_bus/adapters/queue/__init__.py +21 -0
- command_bus/adapters/queue/file.py +193 -0
- command_bus/adapters/queue/in_memory.py +59 -0
- command_bus/adapters/queue/rabbitmq.py +119 -0
- command_bus/adapters/queue/redis.py +67 -0
- command_bus/adapters/queue/sqs.py +45 -0
- command_bus/adapters/response/__init__.py +13 -0
- command_bus/adapters/response/file.py +129 -0
- command_bus/adapters/response/in_memory.py +42 -0
- command_bus/adapters/response/redis.py +56 -0
- command_bus/bus.py +159 -0
- command_bus/interfaces.py +111 -0
- command_bus/parsers/__init__.py +23 -0
- command_bus/parsers/base.py +17 -0
- command_bus/parsers/base64_parser.py +50 -0
- command_bus/parsers/json_parser.py +58 -0
- command_bus/parsers/repr_parser.py +95 -0
- command_bus/registry.py +163 -0
- command_bus/utils.py +45 -0
- deegzlibs_command_bus-2.0.0.dist-info/METADATA +91 -0
- deegzlibs_command_bus-2.0.0.dist-info/RECORD +25 -0
- deegzlibs_command_bus-2.0.0.dist-info/WHEEL +5 -0
- deegzlibs_command_bus-2.0.0.dist-info/top_level.txt +1 -0
command_bus/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DeegzLibs CommandBus: a small command bus with pluggable queue adapters (e.g. SQS).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .bus import CommandBus
|
|
6
|
+
from .interfaces import (
|
|
7
|
+
CommandBusAdapter,
|
|
8
|
+
CommandBusInterface,
|
|
9
|
+
CommandBusRouterInterface,
|
|
10
|
+
CommandMessage,
|
|
11
|
+
CommandHandler,
|
|
12
|
+
ResponseStore,
|
|
13
|
+
TransmissibleBaseModel,
|
|
14
|
+
)
|
|
15
|
+
from .parsers import (
|
|
16
|
+
Base64MessageParser,
|
|
17
|
+
JsonMessageParser,
|
|
18
|
+
MessageParser,
|
|
19
|
+
MessageParserBase,
|
|
20
|
+
ReprMessageParser,
|
|
21
|
+
)
|
|
22
|
+
from .registry import CommandBusRouter, CommandBusRouterEntry, get_qual_name
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Base64MessageParser",
|
|
26
|
+
"CommandBus",
|
|
27
|
+
"CommandBusAdapter",
|
|
28
|
+
"ResponseStore",
|
|
29
|
+
"CommandBusInterface",
|
|
30
|
+
"CommandBusRouter",
|
|
31
|
+
"CommandBusRouterEntry",
|
|
32
|
+
"CommandBusRouterInterface",
|
|
33
|
+
"CommandMessage",
|
|
34
|
+
"CommandHandler",
|
|
35
|
+
"JsonMessageParser",
|
|
36
|
+
"MessageParser",
|
|
37
|
+
"MessageParserBase",
|
|
38
|
+
"ReprMessageParser",
|
|
39
|
+
"TransmissibleBaseModel",
|
|
40
|
+
"get_qual_name",
|
|
41
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Queue and response adapters for the command bus."""
|
|
2
|
+
|
|
3
|
+
from .queue import FileQueueAdapter, InMemoryCommandBusAdapter, SqsCommandBusAdapter
|
|
4
|
+
|
|
5
|
+
__all__ = ["InMemoryCommandBusAdapter", "SqsCommandBusAdapter", "FileQueueAdapter"]
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from .queue import RabbitMqCommandBusAdapter
|
|
9
|
+
|
|
10
|
+
__all__ += ["RabbitMqCommandBusAdapter"]
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from .queue import RedisCommandBusAdapter
|
|
16
|
+
|
|
17
|
+
__all__ += ["RedisCommandBusAdapter"]
|
|
18
|
+
except ImportError:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
from .response import FileResponseStore, InMemoryResponseStore
|
|
22
|
+
|
|
23
|
+
__all__ += ["InMemoryResponseStore", "FileResponseStore"]
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from .response import RedisResponseStore
|
|
27
|
+
|
|
28
|
+
__all__ += ["RedisResponseStore"]
|
|
29
|
+
except ImportError:
|
|
30
|
+
pass
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Queue adapters for the command bus (SQS, RabbitMQ, Redis, in-memory, file, etc.)."""
|
|
2
|
+
|
|
3
|
+
from .file import FileQueueAdapter
|
|
4
|
+
from .in_memory import InMemoryCommandBusAdapter
|
|
5
|
+
from .sqs import SqsCommandBusAdapter
|
|
6
|
+
|
|
7
|
+
__all__ = ["InMemoryCommandBusAdapter", "SqsCommandBusAdapter", "FileQueueAdapter"]
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from .rabbitmq import RabbitMqCommandBusAdapter
|
|
11
|
+
|
|
12
|
+
__all__ += ["RabbitMqCommandBusAdapter"]
|
|
13
|
+
except ImportError:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from .redis import RedisCommandBusAdapter
|
|
18
|
+
|
|
19
|
+
__all__ += ["RedisCommandBusAdapter"]
|
|
20
|
+
except ImportError:
|
|
21
|
+
pass
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""File-based queue adapter for persistent cross-process command queuing."""
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
from ...interfaces import CommandBusAdapter, CommandMessage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileQueueAdapter(CommandBusAdapter):
|
|
14
|
+
"""File-based queue adapter that persists to disk for cross-process access."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
queue_name: str = "events",
|
|
19
|
+
storage_file: Path = None,
|
|
20
|
+
default_visibility_timeout: int = 60
|
|
21
|
+
) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Initialize file-based queue adapter.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
queue_name: Name of the queue
|
|
27
|
+
storage_file: Path to JSON file for storage. Defaults to ~/.qubot/queue.json
|
|
28
|
+
default_visibility_timeout: Default visibility timeout in seconds (how long a message
|
|
29
|
+
is hidden after being retrieved). Defaults to 60 seconds.
|
|
30
|
+
"""
|
|
31
|
+
self.queue_name = queue_name
|
|
32
|
+
self.default_visibility_timeout = default_visibility_timeout
|
|
33
|
+
|
|
34
|
+
if storage_file is None:
|
|
35
|
+
storage_dir = Path.home() / ".qubot"
|
|
36
|
+
storage_dir.mkdir(exist_ok=True)
|
|
37
|
+
storage_file = storage_dir / "queue.json"
|
|
38
|
+
|
|
39
|
+
self.storage_file = Path(storage_file)
|
|
40
|
+
self._lock_file = self.storage_file.with_suffix('.lock')
|
|
41
|
+
self._lock = None
|
|
42
|
+
|
|
43
|
+
def _acquire_lock(self):
|
|
44
|
+
"""Simple file-based lock."""
|
|
45
|
+
self._lock = open(self._lock_file, 'w', encoding='utf-8')
|
|
46
|
+
fcntl.flock(self._lock.fileno(), fcntl.LOCK_EX)
|
|
47
|
+
|
|
48
|
+
def _release_lock(self):
|
|
49
|
+
"""Release file-based lock."""
|
|
50
|
+
if self._lock is not None:
|
|
51
|
+
try:
|
|
52
|
+
fcntl.flock(self._lock.fileno(), fcntl.LOCK_UN)
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
pass
|
|
55
|
+
finally:
|
|
56
|
+
self._lock.close()
|
|
57
|
+
self._lock = None
|
|
58
|
+
|
|
59
|
+
def _load_queue(self) -> List[Dict[str, Any]]:
|
|
60
|
+
"""Load queue from disk."""
|
|
61
|
+
if not self.storage_file.exists():
|
|
62
|
+
return []
|
|
63
|
+
try:
|
|
64
|
+
with open(self.storage_file, 'r', encoding='utf-8') as f:
|
|
65
|
+
return json.load(f)
|
|
66
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
def _save_queue(self, queue: List[Dict[str, Any]]) -> None:
|
|
70
|
+
"""Save queue to disk."""
|
|
71
|
+
try:
|
|
72
|
+
# Create parent directory if it doesn't exist
|
|
73
|
+
self.storage_file.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
with open(self.storage_file, 'w', encoding='utf-8') as f:
|
|
75
|
+
json.dump(queue, f, indent=2)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
raise RuntimeError(f"Failed to save queue: {e}") from e
|
|
78
|
+
|
|
79
|
+
def enqueue(self, message_instance: CommandMessage, delay_seconds: int = 0) -> None:
|
|
80
|
+
"""Add a message to the queue."""
|
|
81
|
+
try:
|
|
82
|
+
self._acquire_lock()
|
|
83
|
+
queue = self._load_queue()
|
|
84
|
+
|
|
85
|
+
# Serialize the message with full module path
|
|
86
|
+
# Format: module.ClassName(arg1=val1, arg2=val2, ...)
|
|
87
|
+
class_name = message_instance.__class__.__name__
|
|
88
|
+
module_name = message_instance.__class__.__module__
|
|
89
|
+
full_class_path = f"{module_name}.{class_name}"
|
|
90
|
+
|
|
91
|
+
# Get the arguments from the message instance
|
|
92
|
+
if hasattr(message_instance, 'model_dump'):
|
|
93
|
+
args_dict = message_instance.model_dump()
|
|
94
|
+
else:
|
|
95
|
+
args_dict = message_instance.__dict__
|
|
96
|
+
|
|
97
|
+
args_str = ", ".join(f"{k}={repr(v)}" for k, v in args_dict.items())
|
|
98
|
+
message_str = f"{full_class_path}({args_str})"
|
|
99
|
+
|
|
100
|
+
queue.append({
|
|
101
|
+
"id": str(uuid.uuid4()),
|
|
102
|
+
"message": message_str,
|
|
103
|
+
"delay_until": time.time() + delay_seconds,
|
|
104
|
+
"queue_name": self.queue_name,
|
|
105
|
+
"hidden_until": 0 # Initially visible
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
self._save_queue(queue)
|
|
109
|
+
finally:
|
|
110
|
+
self._release_lock()
|
|
111
|
+
|
|
112
|
+
def dequeue(self, message_instance: Any) -> None:
|
|
113
|
+
"""Remove a message from the queue."""
|
|
114
|
+
try:
|
|
115
|
+
self._acquire_lock()
|
|
116
|
+
queue = self._load_queue()
|
|
117
|
+
|
|
118
|
+
# Find and remove the message by ID (more reliable than message string)
|
|
119
|
+
msg_id = None
|
|
120
|
+
if hasattr(message_instance, '_data') and isinstance(message_instance._data, dict):
|
|
121
|
+
msg_id = message_instance._data.get("id") # noqa: SLF001
|
|
122
|
+
|
|
123
|
+
if msg_id:
|
|
124
|
+
queue[:] = [msg for msg in queue if msg.get("id") != msg_id]
|
|
125
|
+
else:
|
|
126
|
+
# Fallback to message string matching
|
|
127
|
+
message_str = message_instance.body if hasattr(message_instance, 'body') else str(message_instance)
|
|
128
|
+
queue[:] = [msg for msg in queue if msg.get("message") != message_str]
|
|
129
|
+
|
|
130
|
+
self._save_queue(queue)
|
|
131
|
+
finally:
|
|
132
|
+
self._release_lock()
|
|
133
|
+
|
|
134
|
+
def get_messages(
|
|
135
|
+
self,
|
|
136
|
+
max_messages: int = 1,
|
|
137
|
+
wait_seconds: int = 0, # noqa: ARG002
|
|
138
|
+
visibility_timeout: int = None,
|
|
139
|
+
**kwargs: Any
|
|
140
|
+
) -> List[Any]:
|
|
141
|
+
"""
|
|
142
|
+
Get messages from the queue.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
max_messages: Maximum number of messages to retrieve
|
|
146
|
+
wait_seconds: Not used (file-based queue doesn't support blocking)
|
|
147
|
+
visibility_timeout: How long (in seconds) to hide the message after retrieval.
|
|
148
|
+
If None, uses the adapter's default_visibility_timeout.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
self._acquire_lock()
|
|
152
|
+
queue = self._load_queue()
|
|
153
|
+
current_time = time.time()
|
|
154
|
+
|
|
155
|
+
# Use provided visibility timeout or default
|
|
156
|
+
vis_timeout = visibility_timeout if visibility_timeout is not None else self.default_visibility_timeout
|
|
157
|
+
|
|
158
|
+
# Filter messages for this queue that are:
|
|
159
|
+
# 1. For this queue name
|
|
160
|
+
# 2. Delay has passed (delay_until <= current_time)
|
|
161
|
+
# 3. Not currently hidden (hidden_until <= current_time)
|
|
162
|
+
visible_messages = [
|
|
163
|
+
msg for msg in queue
|
|
164
|
+
if msg.get("queue_name") == self.queue_name
|
|
165
|
+
and msg.get("delay_until", 0) <= current_time
|
|
166
|
+
and msg.get("hidden_until", 0) <= current_time
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
# Limit to max_messages
|
|
170
|
+
messages_to_return = visible_messages[:max_messages]
|
|
171
|
+
|
|
172
|
+
# Update hidden_until for returned messages (only if visibility_timeout > 0)
|
|
173
|
+
if vis_timeout > 0:
|
|
174
|
+
for msg in messages_to_return:
|
|
175
|
+
msg["hidden_until"] = current_time + vis_timeout
|
|
176
|
+
|
|
177
|
+
# Save the updated queue with new hidden_until values
|
|
178
|
+
if messages_to_return:
|
|
179
|
+
self._save_queue(queue)
|
|
180
|
+
|
|
181
|
+
# Return as message objects (the bus will parse them)
|
|
182
|
+
class QueueMessage:
|
|
183
|
+
def __init__(self, msg_data: Dict[str, Any]):
|
|
184
|
+
self.body = msg_data["message"]
|
|
185
|
+
self._data = msg_data
|
|
186
|
+
|
|
187
|
+
def __repr__(self):
|
|
188
|
+
return self.body
|
|
189
|
+
|
|
190
|
+
return [QueueMessage(msg) for msg in messages_to_return]
|
|
191
|
+
finally:
|
|
192
|
+
self._release_lock()
|
|
193
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""In-memory command bus adapter for tests or in-process use."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
from ...interfaces import CommandBusAdapter, CommandMessage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _InMemoryMessage:
|
|
10
|
+
"""Message wrapper with .body and .delete() for bus compatibility."""
|
|
11
|
+
|
|
12
|
+
__slots__ = ("body",)
|
|
13
|
+
|
|
14
|
+
def __init__(self, body: str) -> None:
|
|
15
|
+
self.body = body
|
|
16
|
+
|
|
17
|
+
def delete(self) -> None:
|
|
18
|
+
"""No-op: message already removed from queue when get_messages() popped it."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InMemoryCommandBusAdapter(CommandBusAdapter):
|
|
23
|
+
"""
|
|
24
|
+
In-memory FIFO queue adapter. Useful for tests or single-process command dispatch.
|
|
25
|
+
delay_seconds is ignored (no native delay); messages are available immediately.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, queue_name: str = "default") -> None:
|
|
29
|
+
self.queue_name = queue_name
|
|
30
|
+
self._queue: deque[str] = deque()
|
|
31
|
+
|
|
32
|
+
def enqueue(
|
|
33
|
+
self,
|
|
34
|
+
message_instance: CommandMessage,
|
|
35
|
+
delay_seconds: int = 0,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Append message to the queue. delay_seconds is ignored."""
|
|
38
|
+
self._queue.append(str(message_instance))
|
|
39
|
+
|
|
40
|
+
def dequeue(self, message_instance: Any) -> None:
|
|
41
|
+
"""No-op: message was already removed when get_messages() popped it."""
|
|
42
|
+
if hasattr(message_instance, "delete"):
|
|
43
|
+
message_instance.delete()
|
|
44
|
+
|
|
45
|
+
def get_messages(
|
|
46
|
+
self,
|
|
47
|
+
max_messages: int = 1,
|
|
48
|
+
wait_seconds: int = 0,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> List[_InMemoryMessage]:
|
|
51
|
+
"""Pop up to max_messages from the queue. wait_seconds is ignored (no blocking)."""
|
|
52
|
+
out: List[_InMemoryMessage] = []
|
|
53
|
+
for _ in range(max_messages):
|
|
54
|
+
try:
|
|
55
|
+
body = self._queue.popleft()
|
|
56
|
+
except IndexError:
|
|
57
|
+
break
|
|
58
|
+
out.append(_InMemoryMessage(body=body))
|
|
59
|
+
return out
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""RabbitMQ-backed command bus adapter."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
|
|
5
|
+
import pika
|
|
6
|
+
from pika.adapters.blocking_connection import BlockingChannel
|
|
7
|
+
|
|
8
|
+
from ...interfaces import CommandBusAdapter, CommandMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _RabbitMQMessage:
|
|
12
|
+
"""Wrapper so RabbitMQ messages have .body and .delete() like SQS messages."""
|
|
13
|
+
|
|
14
|
+
__slots__ = ("body", "_channel", "_delivery_tag")
|
|
15
|
+
|
|
16
|
+
def __init__(self, body: str, channel: BlockingChannel, delivery_tag: int) -> None:
|
|
17
|
+
self.body = body
|
|
18
|
+
self._channel = channel
|
|
19
|
+
self._delivery_tag = delivery_tag
|
|
20
|
+
|
|
21
|
+
def delete(self) -> None:
|
|
22
|
+
self._channel.basic_ack(self._delivery_tag)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RabbitMqCommandBusAdapter(CommandBusAdapter):
|
|
26
|
+
"""CommandBus adapter using RabbitMQ for queue operations (via pika)."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
queue_name: str,
|
|
31
|
+
connection_url: Optional[str] = None,
|
|
32
|
+
connection_params: Optional[pika.ConnectionParameters] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
if not connection_url and not connection_params:
|
|
35
|
+
raise ValueError("Provide either connection_url or connection_params")
|
|
36
|
+
self.queue_name = queue_name
|
|
37
|
+
self._connection_url = connection_url
|
|
38
|
+
self._connection_params = connection_params
|
|
39
|
+
self._connection: Optional[pika.BlockingConnection] = None
|
|
40
|
+
self._channel: Optional[BlockingChannel] = None
|
|
41
|
+
|
|
42
|
+
def _ensure_connection(self) -> BlockingChannel:
|
|
43
|
+
if self._channel is None or self._channel.is_closed:
|
|
44
|
+
if self._connection_url:
|
|
45
|
+
self._connection = pika.BlockingConnection(
|
|
46
|
+
pika.URLParameters(self._connection_url)
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
self._connection = pika.BlockingConnection(self._connection_params)
|
|
50
|
+
self._channel = self._connection.channel()
|
|
51
|
+
self._channel.queue_declare(queue=self.queue_name, durable=True)
|
|
52
|
+
return self._channel
|
|
53
|
+
|
|
54
|
+
def _connection_for_publish(self) -> tuple:
|
|
55
|
+
"""Short-lived connection for publishing (no shared state)."""
|
|
56
|
+
if self._connection_url:
|
|
57
|
+
conn = pika.BlockingConnection(pika.URLParameters(self._connection_url))
|
|
58
|
+
else:
|
|
59
|
+
conn = pika.BlockingConnection(self._connection_params)
|
|
60
|
+
ch = conn.channel()
|
|
61
|
+
ch.queue_declare(queue=self.queue_name, durable=True)
|
|
62
|
+
return conn, ch
|
|
63
|
+
|
|
64
|
+
def enqueue(
|
|
65
|
+
self,
|
|
66
|
+
message_instance: CommandMessage,
|
|
67
|
+
delay_seconds: int = 0,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Add a message to the queue. (delay_seconds is ignored; use a delayed-exchange plugin for delays.)"""
|
|
70
|
+
conn, ch = self._connection_for_publish()
|
|
71
|
+
try:
|
|
72
|
+
ch.basic_publish(
|
|
73
|
+
exchange="",
|
|
74
|
+
routing_key=self.queue_name,
|
|
75
|
+
body=str(message_instance),
|
|
76
|
+
properties=pika.BasicProperties(delivery_mode=2), # persistent
|
|
77
|
+
)
|
|
78
|
+
finally:
|
|
79
|
+
conn.close()
|
|
80
|
+
|
|
81
|
+
def dequeue(self, message_instance: Any) -> None:
|
|
82
|
+
"""Ack the message (remove from queue after successful processing)."""
|
|
83
|
+
if hasattr(message_instance, "delete"):
|
|
84
|
+
message_instance.delete()
|
|
85
|
+
else:
|
|
86
|
+
raise TypeError(
|
|
87
|
+
"message_instance must be a _RabbitMQMessage with .delete()"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def get_messages(
|
|
91
|
+
self,
|
|
92
|
+
max_messages: int = 1,
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> List[_RabbitMQMessage]:
|
|
95
|
+
"""Fetch messages from the queue. Uses a shared connection/channel for acks."""
|
|
96
|
+
channel = self._ensure_connection()
|
|
97
|
+
out: List[_RabbitMQMessage] = []
|
|
98
|
+
for _ in range(max_messages):
|
|
99
|
+
method_frame, _, body = channel.basic_get(queue=self.queue_name)
|
|
100
|
+
if method_frame is None:
|
|
101
|
+
break
|
|
102
|
+
body_str = body.decode("utf-8") if isinstance(body, bytes) else body
|
|
103
|
+
out.append(
|
|
104
|
+
_RabbitMQMessage(
|
|
105
|
+
body=body_str,
|
|
106
|
+
channel=channel,
|
|
107
|
+
delivery_tag=method_frame.delivery_tag,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
def close(self) -> None:
|
|
113
|
+
"""Close the consumer connection (optional; call when done with get_messages)."""
|
|
114
|
+
if self._channel and not self._channel.is_closed:
|
|
115
|
+
self._channel.close()
|
|
116
|
+
if self._connection and self._connection.is_open:
|
|
117
|
+
self._connection.close()
|
|
118
|
+
self._channel = None
|
|
119
|
+
self._connection = None
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Redis-backed command bus adapter using Redis Lists (LPUSH/BRPOP)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, List
|
|
4
|
+
|
|
5
|
+
from ...interfaces import CommandBusAdapter, CommandMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _RedisMessage:
|
|
9
|
+
"""Wrapper so Redis messages have .body and .delete() like SQS/RabbitMQ. Message already popped, so delete is no-op."""
|
|
10
|
+
|
|
11
|
+
__slots__ = ("body",)
|
|
12
|
+
|
|
13
|
+
def __init__(self, body: str) -> None:
|
|
14
|
+
self.body = body
|
|
15
|
+
|
|
16
|
+
def delete(self) -> None:
|
|
17
|
+
"""No-op: message was already removed from the list when we BRPOP'd."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RedisCommandBusAdapter(CommandBusAdapter):
|
|
22
|
+
"""
|
|
23
|
+
CommandBus adapter using a Redis List for queue operations (LPUSH to enqueue, BRPOP to consume).
|
|
24
|
+
FIFO: producers LPUSH, workers BRPOP. delay_seconds is not supported (use a delayed-queue pattern separately if needed).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, redis_client: Any, queue_name: str) -> None:
|
|
28
|
+
self._redis = redis_client
|
|
29
|
+
self.queue_name = queue_name
|
|
30
|
+
|
|
31
|
+
def enqueue(
|
|
32
|
+
self,
|
|
33
|
+
message_instance: CommandMessage,
|
|
34
|
+
delay_seconds: int = 0,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Add a message to the queue. delay_seconds is ignored (Redis List has no native delay)."""
|
|
37
|
+
self._redis.lpush(self.queue_name, str(message_instance))
|
|
38
|
+
|
|
39
|
+
def dequeue(self, message_instance: Any) -> None:
|
|
40
|
+
"""No-op: message was already removed when get_messages() used BRPOP."""
|
|
41
|
+
if hasattr(message_instance, "delete"):
|
|
42
|
+
message_instance.delete()
|
|
43
|
+
|
|
44
|
+
def get_messages(
|
|
45
|
+
self,
|
|
46
|
+
max_messages: int = 1,
|
|
47
|
+
wait_seconds: int = 0,
|
|
48
|
+
**kwargs: Any,
|
|
49
|
+
) -> List[_RedisMessage]:
|
|
50
|
+
"""Fetch messages from the queue. Uses BRPOP (blocking) when wait_seconds > 0, else RPOP (non-blocking)."""
|
|
51
|
+
out: List[_RedisMessage] = []
|
|
52
|
+
timeout = max(0, wait_seconds)
|
|
53
|
+
for i in range(max_messages):
|
|
54
|
+
if timeout > 0 and i == 0:
|
|
55
|
+
result = self._redis.brpop(self.queue_name, timeout=timeout)
|
|
56
|
+
else:
|
|
57
|
+
result = self._redis.rpop(self.queue_name)
|
|
58
|
+
if result is None:
|
|
59
|
+
break
|
|
60
|
+
# brpop returns (key, value); rpop returns value only
|
|
61
|
+
if isinstance(result, (list, tuple)):
|
|
62
|
+
_, raw = result
|
|
63
|
+
else:
|
|
64
|
+
raw = result
|
|
65
|
+
body = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
66
|
+
out.append(_RedisMessage(body=body))
|
|
67
|
+
return out
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""SQS-backed command bus adapter."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ...interfaces import CommandBusAdapter, CommandMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SqsCommandBusAdapter(CommandBusAdapter):
|
|
9
|
+
"""CommandBus adapter using AWS SQS for queue operations."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, queue_name: str, sqs_client: Any) -> None:
|
|
12
|
+
self.queue_name = queue_name
|
|
13
|
+
self.sqs_client = sqs_client
|
|
14
|
+
self.sqs_queue = self.sqs_client.get_queue_by_name(
|
|
15
|
+
QueueName=self.queue_name,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def enqueue(
|
|
19
|
+
self,
|
|
20
|
+
message_instance: CommandMessage,
|
|
21
|
+
delay_seconds: int = 0,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Add a message to the queue."""
|
|
24
|
+
self.sqs_queue.send_message(
|
|
25
|
+
MessageBody=str(message_instance),
|
|
26
|
+
DelaySeconds=delay_seconds,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def dequeue(self, message_instance: Any) -> None:
|
|
30
|
+
"""Remove a message from the queue (e.g. after successful processing)."""
|
|
31
|
+
message_instance.delete()
|
|
32
|
+
|
|
33
|
+
def get_messages(
|
|
34
|
+
self,
|
|
35
|
+
max_messages: int = 1,
|
|
36
|
+
wait_seconds: int = 0,
|
|
37
|
+
visibility_timeout: int = 60,
|
|
38
|
+
) -> Any:
|
|
39
|
+
"""Fetch messages from the queue."""
|
|
40
|
+
return self.sqs_queue.receive_messages(
|
|
41
|
+
MessageAttributeNames=["ALL"],
|
|
42
|
+
MaxNumberOfMessages=max_messages,
|
|
43
|
+
WaitTimeSeconds=wait_seconds,
|
|
44
|
+
VisibilityTimeout=visibility_timeout,
|
|
45
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Response store adapters for request/response over the command bus (Redis, in-memory, file, etc.)."""
|
|
2
|
+
|
|
3
|
+
from .file import FileResponseStore
|
|
4
|
+
from .in_memory import InMemoryResponseStore
|
|
5
|
+
|
|
6
|
+
__all__ = ["InMemoryResponseStore", "FileResponseStore"]
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from .redis import RedisResponseStore
|
|
10
|
+
|
|
11
|
+
__all__ += ["RedisResponseStore"]
|
|
12
|
+
except ImportError:
|
|
13
|
+
pass
|