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.
@@ -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