talus 1.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.
- talus/__init__.py +0 -0
- talus/_version.py +16 -0
- talus/base.py +117 -0
- talus/consumer.py +179 -0
- talus/models/__init__.py +0 -0
- talus/models/binding.py +46 -0
- talus/models/connection_parameters.py +53 -0
- talus/models/exchange.py +23 -0
- talus/models/message.py +125 -0
- talus/models/processor.py +192 -0
- talus/models/queue.py +20 -0
- talus/models/retryer.py +91 -0
- talus/producer.py +138 -0
- talus/tests/__init__.py +0 -0
- talus/tests/conftest.py +168 -0
- talus/tests/models/__init__.py +0 -0
- talus/tests/models/test_binding.py +67 -0
- talus/tests/models/test_connection_parameters.py +14 -0
- talus/tests/models/test_message.py +108 -0
- talus/tests/models/test_processor.py +195 -0
- talus/tests/models/test_queue.py +21 -0
- talus/tests/models/test_retryer.py +50 -0
- talus/tests/test_base.py +111 -0
- talus/tests/test_consumer.py +109 -0
- talus/tests/test_producer.py +28 -0
- talus-1.0.0.dist-info/LICENSE +9 -0
- talus-1.0.0.dist-info/METADATA +129 -0
- talus-1.0.0.dist-info/RECORD +30 -0
- talus-1.0.0.dist-info/WHEEL +5 -0
- talus-1.0.0.dist-info/top_level.txt +1 -0
talus/__init__.py
ADDED
|
File without changes
|
talus/_version.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# file generated by setuptools_scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
|
+
else:
|
|
8
|
+
VERSION_TUPLE = object
|
|
9
|
+
|
|
10
|
+
version: str
|
|
11
|
+
__version__: str
|
|
12
|
+
__version_tuple__: VERSION_TUPLE
|
|
13
|
+
version_tuple: VERSION_TUPLE
|
|
14
|
+
|
|
15
|
+
__version__ = version = '1.0.0'
|
|
16
|
+
__version_tuple__ = version_tuple = (1, 0, 0)
|
talus/base.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base wrapper definition shared by both consumer and producer wrappers
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import pika
|
|
8
|
+
from tenacity import Retrying
|
|
9
|
+
|
|
10
|
+
from talus.models.connection_parameters import ConnectionParameterFactory
|
|
11
|
+
from talus.models.queue import Queue
|
|
12
|
+
from talus.models.retryer import ConnectionRetryerFactory
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DurableConnection:
|
|
18
|
+
"""
|
|
19
|
+
RabbitMQ connector that establishes a blocking connection and channel
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
connection_parameters: pika.ConnectionParameters
|
|
25
|
+
| Callable[[], pika.ConnectionParameters] = ConnectionParameterFactory(),
|
|
26
|
+
connection_retryer: Retrying | Callable[[], Retrying] = ConnectionRetryerFactory(),
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Constructor for a durable connection to RabbitMQ.
|
|
30
|
+
:param connection_parameters: A pika.ConnectionParameters object or one resulting
|
|
31
|
+
from a callable that returns a ConnectionParameters object.
|
|
32
|
+
:param connection_retryer: A tenacity.Retrying object or one resulting from a callable that returns
|
|
33
|
+
a Retrying object.
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(connection_parameters, pika.ConnectionParameters):
|
|
36
|
+
self.connection_parameters = connection_parameters
|
|
37
|
+
else: # factory
|
|
38
|
+
self.connection_parameters = connection_parameters()
|
|
39
|
+
if isinstance(connection_retryer, Retrying):
|
|
40
|
+
self.connection_retryer = connection_retryer
|
|
41
|
+
else: # factory
|
|
42
|
+
self.connection_retryer = connection_retryer()
|
|
43
|
+
self.connection: pika.BlockingConnection | None = None
|
|
44
|
+
self.channel: pika.adapters.blocking_connection.BlockingChannel | None = None
|
|
45
|
+
|
|
46
|
+
def connect(self):
|
|
47
|
+
"""
|
|
48
|
+
Connect to the RabbitMQ server retrying errors configured in the connection_retryer.
|
|
49
|
+
"""
|
|
50
|
+
if not self.is_connected:
|
|
51
|
+
self.connection_retryer(self._connect)
|
|
52
|
+
|
|
53
|
+
def _connect(self):
|
|
54
|
+
"""
|
|
55
|
+
Configures and initiates connection to the RabbitMQ server.
|
|
56
|
+
"""
|
|
57
|
+
logger.debug(
|
|
58
|
+
f"Attempt to connect to RabbitMQ: connection_params={self.connection_parameters}"
|
|
59
|
+
)
|
|
60
|
+
self.connection = pika.BlockingConnection(self.connection_parameters)
|
|
61
|
+
logger.info(f"Connection Created")
|
|
62
|
+
self.channel = self.connection.channel()
|
|
63
|
+
logger.info("Channel Created")
|
|
64
|
+
logger.info(f"Connected to RabbitMQ: connection={self.connection_parameters}")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_connected(self):
|
|
68
|
+
"""
|
|
69
|
+
Current state of the connection. Only updated when the connection is used.
|
|
70
|
+
|
|
71
|
+
:return: Latest connection state
|
|
72
|
+
"""
|
|
73
|
+
if self.connection is not None:
|
|
74
|
+
return self.connection.is_open
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def disconnect(self):
|
|
78
|
+
"""
|
|
79
|
+
Closes connection and related channels to the RabbitMQ Server.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
if self.is_connected:
|
|
83
|
+
self.connection.close()
|
|
84
|
+
logger.info(f"Disconnected from RabbitMQ: " f"connection={self.connection_parameters}")
|
|
85
|
+
|
|
86
|
+
def create_queue(self, queue: Queue):
|
|
87
|
+
"""
|
|
88
|
+
Create a queue on an already opened connection.
|
|
89
|
+
"""
|
|
90
|
+
self.channel.queue_declare(
|
|
91
|
+
queue=queue.name,
|
|
92
|
+
durable=queue.durable,
|
|
93
|
+
passive=queue.passive,
|
|
94
|
+
auto_delete=queue.auto_delete,
|
|
95
|
+
exclusive=queue.exclusive,
|
|
96
|
+
arguments=queue.arguments,
|
|
97
|
+
)
|
|
98
|
+
logger.info(f"Queue Created: queue={queue.name}")
|
|
99
|
+
|
|
100
|
+
def __enter__(self):
|
|
101
|
+
"""
|
|
102
|
+
Entry for context manager.
|
|
103
|
+
|
|
104
|
+
:return: connected instance of self
|
|
105
|
+
"""
|
|
106
|
+
self.connect()
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(self, exc_type, value, traceback):
|
|
110
|
+
"""
|
|
111
|
+
Exit for context manager which disconnects from rabbitmq
|
|
112
|
+
"""
|
|
113
|
+
self.disconnect()
|
|
114
|
+
|
|
115
|
+
def __repr__(self):
|
|
116
|
+
"""Representation of the DurableConnection object"""
|
|
117
|
+
return f"{self.__class__.__name__}(connection_parameters={self.connection_parameters!r}), connection_retryer={self.connection_retryer!r})"
|
talus/consumer.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Consumer implementation of the base connection wrapper
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Callable
|
|
6
|
+
from typing import Generator
|
|
7
|
+
|
|
8
|
+
import pika.adapters.blocking_connection
|
|
9
|
+
from tenacity import Retrying
|
|
10
|
+
|
|
11
|
+
from talus.base import DurableConnection
|
|
12
|
+
from talus.models.connection_parameters import ConsumerConnectionParameterFactory
|
|
13
|
+
from talus.models.queue import Queue
|
|
14
|
+
from talus.models.retryer import ConnectionRetryerFactory
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
LISTEN_CALLBACK_TYPE = Callable[
|
|
20
|
+
[
|
|
21
|
+
pika.adapters.blocking_connection.BlockingChannel,
|
|
22
|
+
pika.spec.Basic.Deliver,
|
|
23
|
+
pika.spec.BasicProperties,
|
|
24
|
+
bytes,
|
|
25
|
+
],
|
|
26
|
+
None,
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DurableConsumer(DurableConnection):
|
|
31
|
+
"""
|
|
32
|
+
RabbitMQ connector for consuming from a single queue in RabbitMQ.
|
|
33
|
+
>>> from talus.models.queue import Queue
|
|
34
|
+
>>> from talus.models.connection_parameters import ConsumerConnectionParameterFactory
|
|
35
|
+
>>> from talus.models.retryer import ConnectionRetryerFactory
|
|
36
|
+
>>> from talus.consumer import DurableConsumer
|
|
37
|
+
>>> from talus.models.processor import MessageProcessorBase
|
|
38
|
+
>>> from talus.models.message import ConsumeMessageBase
|
|
39
|
+
>>> consume_queue = Queue(name="test_queue")
|
|
40
|
+
>>> class MessageProcessor(MessageProcessorBase):
|
|
41
|
+
>>> def process_message(message: ConsumeMessageBase):
|
|
42
|
+
>>> print(message)
|
|
43
|
+
>>> with DurableConsumer(consume_queue=consume_queue, prefetch_count=1, connection_parameters=ConsumerConnectionParameterFactory(), connection_retryer=ConnectionRetryerFactory()) as consumer:
|
|
44
|
+
>>> with MessageProcessor() as message_processor:
|
|
45
|
+
>>> consumer.listen(message_processor)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
consume_queue: Queue,
|
|
51
|
+
prefetch_count: int = 1,
|
|
52
|
+
connection_parameters: pika.ConnectionParameters
|
|
53
|
+
| Callable[[], pika.ConnectionParameters] = ConsumerConnectionParameterFactory(),
|
|
54
|
+
connection_retryer: Retrying | Callable[[], Retrying] = ConnectionRetryerFactory(),
|
|
55
|
+
):
|
|
56
|
+
"""
|
|
57
|
+
Constructor for the consumer connector
|
|
58
|
+
|
|
59
|
+
:param consume_queue: Queue to consume messages from
|
|
60
|
+
|
|
61
|
+
:param prefetch_count: Number of un-Acked message delivered at a time
|
|
62
|
+
|
|
63
|
+
:param connection_parameters: A pika.ConnectionParameters object or one resulting
|
|
64
|
+
from a callable that returns a ConnectionParameters object.
|
|
65
|
+
|
|
66
|
+
:param connection_retryer: A tenacity.Retrying object or one resulting from a callable that returns
|
|
67
|
+
a Retrying object.
|
|
68
|
+
"""
|
|
69
|
+
super().__init__(
|
|
70
|
+
connection_parameters=connection_parameters,
|
|
71
|
+
connection_retryer=connection_retryer,
|
|
72
|
+
)
|
|
73
|
+
self.consume_queue = consume_queue
|
|
74
|
+
self.prefetch_count = prefetch_count
|
|
75
|
+
|
|
76
|
+
def _connect(self) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Configures and initiates consumer connection to the RabbitMQ server which includes
|
|
79
|
+
setting up the queue to consume from and the prefetch count.
|
|
80
|
+
"""
|
|
81
|
+
super()._connect()
|
|
82
|
+
self.channel.basic_qos(prefetch_count=self.prefetch_count)
|
|
83
|
+
self.create_queue(queue=self.consume_queue)
|
|
84
|
+
|
|
85
|
+
def _listen(self, callback: LISTEN_CALLBACK_TYPE) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Listens for messages on the channel configured on the consumer instance
|
|
88
|
+
|
|
89
|
+
:param callback: Function to execute when a message is received. with the signature
|
|
90
|
+
(ch, method, properties, body).
|
|
91
|
+
ch: Copy of the channel used to acknowledge receipt (pika.Channel)
|
|
92
|
+
method: Management keys for the delivered message e.g. delivery mode (pika.spec.Basic.Deliver)
|
|
93
|
+
properties: Message properties (pika.spec.BasicProperties)
|
|
94
|
+
body: Message body for a transfer message (bytes)
|
|
95
|
+
"""
|
|
96
|
+
self.connect()
|
|
97
|
+
logger.info(f"Starting Listener on Queue: consumer_queue={self.consume_queue}")
|
|
98
|
+
self.channel.basic_consume(queue=self.consume_queue.name, on_message_callback=callback)
|
|
99
|
+
self.channel.start_consuming()
|
|
100
|
+
|
|
101
|
+
def listen(self, message_processor: LISTEN_CALLBACK_TYPE) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Retries calls to _listen and executes the message_processor callback when a message is received.
|
|
104
|
+
This method is blocking and will not return until the connection is closed.
|
|
105
|
+
:param message_processor: Callable to execute when a message is received. with the signature
|
|
106
|
+
(channel, method, properties, body).
|
|
107
|
+
channel: Copy of the channel used to acknowledge receipt (pika.Channel)
|
|
108
|
+
method: Management keys for the delivered message e.g. delivery mode (pika.spec.Basic.Deliver)
|
|
109
|
+
properties: Message properties (pika.spec.BasicProperties)
|
|
110
|
+
body: Message body for a transfer message (bytes)
|
|
111
|
+
and returns None.
|
|
112
|
+
"""
|
|
113
|
+
self.connection_retryer(self._listen, callback=message_processor)
|
|
114
|
+
|
|
115
|
+
def consume_generator(self, auto_ack=False, inactivity_timeout: float = 0.1) -> Generator:
|
|
116
|
+
"""
|
|
117
|
+
Creates a generator for messages that are on the instance consumer_queue.
|
|
118
|
+
Retry logic is not applied to prevent the resetting of the generator cursor
|
|
119
|
+
|
|
120
|
+
:param auto_ack: Automatically acknowledge messages
|
|
121
|
+
:param inactivity_timeout: Number of seconds to wait for a message before returning None
|
|
122
|
+
|
|
123
|
+
:return: Generator of (method, properties, body)
|
|
124
|
+
"""
|
|
125
|
+
self.connect()
|
|
126
|
+
logger.info(f"Creating consumer generator on Queue: consumer_queue={self.consume_queue}")
|
|
127
|
+
return self.channel.consume(
|
|
128
|
+
queue=self.consume_queue.name, auto_ack=auto_ack, inactivity_timeout=inactivity_timeout
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def cancel_consume_generator(self) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Resets the active consume generator
|
|
134
|
+
:return: None
|
|
135
|
+
"""
|
|
136
|
+
logger.info(f"Cancelling consumer generator on Queue: consumer_queue={self.consume_queue}")
|
|
137
|
+
self.channel.cancel()
|
|
138
|
+
|
|
139
|
+
def acknowledge_message(self, delivery_tag, multiple=False) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Record a message as acknowledged.
|
|
142
|
+
Retry logic is not applied since creating a new channel would be unable
|
|
143
|
+
to acknowledge the message received on the now dead channel
|
|
144
|
+
|
|
145
|
+
:param delivery_tag: method.delivery_tag
|
|
146
|
+
|
|
147
|
+
:param multiple: Acknowledge multiple messages by setting to True and acknowledging the last message
|
|
148
|
+
|
|
149
|
+
:return: None
|
|
150
|
+
"""
|
|
151
|
+
self.channel.basic_ack(delivery_tag, multiple)
|
|
152
|
+
|
|
153
|
+
def reject_message(self, delivery_tag) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Record a message as rejected. Will go to dead letter exchange if configured on the server.
|
|
156
|
+
Retry logic is not applied since creating a new channel would be unable
|
|
157
|
+
to use the delivery tag received on the now dead channel
|
|
158
|
+
|
|
159
|
+
:param delivery_tag: method.delivery_tag
|
|
160
|
+
|
|
161
|
+
:return: None
|
|
162
|
+
"""
|
|
163
|
+
self.channel.basic_reject(delivery_tag=delivery_tag, requeue=False)
|
|
164
|
+
|
|
165
|
+
def requeue_message(self, delivery_tag) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Return message back to the queue.
|
|
168
|
+
Retry logic is not applied since creating a new channel would be unable
|
|
169
|
+
to use the delivery tag received on the now dead channel
|
|
170
|
+
|
|
171
|
+
:param delivery_tag: method.delivery_tag
|
|
172
|
+
|
|
173
|
+
:return: None
|
|
174
|
+
"""
|
|
175
|
+
self.channel.basic_nack(delivery_tag=delivery_tag, requeue=True)
|
|
176
|
+
|
|
177
|
+
def __repr__(self):
|
|
178
|
+
"""Representation of the DurableConsumer instance."""
|
|
179
|
+
return f"{self.__class__.__name__}(consume_queue={self.consume_queue !r}, prefetch_count={self.prefetch_count}, connection_parameters={self.connection_parameters!r}, connection_retryer={self.connection_retryer!r})"
|
talus/models/__init__.py
ADDED
|
File without changes
|
talus/models/binding.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bindings contain the many-to-many relationship between a message routing key and a queue on an exchange.
|
|
3
|
+
"""
|
|
4
|
+
from typing import Type
|
|
5
|
+
|
|
6
|
+
from pydantic import AfterValidator
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from pydantic import ConfigDict
|
|
9
|
+
from typing_extensions import Annotated
|
|
10
|
+
|
|
11
|
+
from talus.models.message import PublishMessageBase
|
|
12
|
+
from talus.models.queue import Queue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_routing_key(message: Type[PublishMessageBase]):
|
|
16
|
+
"""Custom pydantic validator to ensure that the routing key is not empty."""
|
|
17
|
+
if not message.default_routing_key:
|
|
18
|
+
raise ValueError("default_routing_key must not be empty")
|
|
19
|
+
return message
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Binding(BaseModel):
|
|
23
|
+
"""
|
|
24
|
+
Binding configuration.
|
|
25
|
+
|
|
26
|
+
>>> from talus.models.binding import Binding
|
|
27
|
+
>>> from talus.models.message import PublishMessageBase
|
|
28
|
+
>>> from talus.models.queue import Queue
|
|
29
|
+
>>> queue = Queue(name="my.queue")
|
|
30
|
+
>>> class PublishMessage(PublishMessageBase):
|
|
31
|
+
>>> default_routing_key = "my.routing.key"
|
|
32
|
+
>>> binding = Binding(message=PublishMessage, queue=queue)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
36
|
+
|
|
37
|
+
message: Annotated[Type[PublishMessageBase], AfterValidator(validate_routing_key)]
|
|
38
|
+
queue: Queue
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def routing_key(self) -> str:
|
|
42
|
+
return self.message.default_routing_key
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def queue_name(self) -> str:
|
|
46
|
+
return self.queue.name
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Connection Parameter Factory Model"""
|
|
2
|
+
import pika
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
MINUTES_1 = 60
|
|
8
|
+
HOUR_1 = 3600
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConnectionParameterFactory(BaseModel):
|
|
12
|
+
"""
|
|
13
|
+
Translator for the connection configuration to a pika.ConnectionParameters object.
|
|
14
|
+
>>> from talus.models.connection_parameters import ConnectionParameterFactory
|
|
15
|
+
>>> factory = ConnectionParameterFactory()
|
|
16
|
+
>>> connection_parameters = factory()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
rabbitmq_host: str = Field(
|
|
20
|
+
default="127.0.0.1"
|
|
21
|
+
) # Host name or IP of the rabbitMQ server. e.g. 127.0.0.1
|
|
22
|
+
rabbitmq_port: int = 5672 # Port the rabbitmq server listens on e.g. 5672
|
|
23
|
+
rabbitmq_user: str = Field(default="guest") # Username for the rabbitMQ server e.g. guest
|
|
24
|
+
rabbitmq_pass: str = Field(default="guest") # Password for the rabbitMQ server e.g. guest
|
|
25
|
+
connection_name: str = Field(
|
|
26
|
+
default="default_connection"
|
|
27
|
+
) # Name of the connection that will be visible in the rabbitmq admin console
|
|
28
|
+
# Controls AMQP heartbeat timeout negotiation
|
|
29
|
+
# during connection tuning. An integer value always overrides the value
|
|
30
|
+
# proposed by broker. Use 0 to deactivate heartbeats and None to always
|
|
31
|
+
# accept the broker's proposal.
|
|
32
|
+
heartbeat: int | None = None
|
|
33
|
+
|
|
34
|
+
def __call__(self) -> pika.ConnectionParameters:
|
|
35
|
+
return pika.ConnectionParameters(
|
|
36
|
+
host=self.rabbitmq_host,
|
|
37
|
+
port=self.rabbitmq_port,
|
|
38
|
+
credentials=pika.credentials.PlainCredentials(self.rabbitmq_user, self.rabbitmq_pass),
|
|
39
|
+
heartbeat=self.heartbeat,
|
|
40
|
+
client_properties={"connection_name": self.connection_name},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConsumerConnectionParameterFactory(ConnectionParameterFactory):
|
|
45
|
+
"""Consumer Connection Parameter Factory Model which defaults to a 1 minute heartbeat."""
|
|
46
|
+
|
|
47
|
+
heartbeat: int = MINUTES_1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ProducerConnectionParameterFactory(ConnectionParameterFactory):
|
|
51
|
+
"""Producer Connection Parameter Factory Model which defaults to a 1 hour heartbeat."""
|
|
52
|
+
|
|
53
|
+
heartbeat: int = HOUR_1
|
talus/models/exchange.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Exchange models."""
|
|
2
|
+
from pika.exchange_type import ExchangeType
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Exchange(BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
Exchange model.
|
|
9
|
+
|
|
10
|
+
>>> from talus.models.exchange import Exchange
|
|
11
|
+
>>> exchange = Exchange(name="my.exchange")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
name: str = "default.x"
|
|
15
|
+
type: ExchangeType = ExchangeType.direct
|
|
16
|
+
passive: bool = False
|
|
17
|
+
durable: bool = True
|
|
18
|
+
auto_delete: bool = False
|
|
19
|
+
internal: bool = False
|
|
20
|
+
arguments: dict | None = None
|
|
21
|
+
|
|
22
|
+
def __str__(self):
|
|
23
|
+
return self.name
|
talus/models/message.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module encapsulating message structure to facilitate use with the Consumer and Producer wrappers
|
|
3
|
+
"""
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Type
|
|
6
|
+
|
|
7
|
+
import pika.spec
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from pydantic import ConfigDict
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MessageBodyBase(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
Base class for message schemas which can be used to validate message bodies.
|
|
16
|
+
This is a pydantic.BaseModel so each message body schema can be validated. Extend this class
|
|
17
|
+
to add fields to a new message body schema. For Example:
|
|
18
|
+
>>> class ObjectBucketBody(MessageBodyBase):
|
|
19
|
+
>>> objectName: str
|
|
20
|
+
>>> bucket: str
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="allow")
|
|
24
|
+
|
|
25
|
+
conversationId: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _MessageBase:
|
|
29
|
+
"""
|
|
30
|
+
Base class for messages establishing a common interface for use by DurableConnections.
|
|
31
|
+
Not intended for direct use. Use ConsumeMessageBase or PublishMessageBase instead.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
message_body_cls: Type[MessageBodyBase] = MessageBodyBase
|
|
35
|
+
|
|
36
|
+
def __init__(self, routing_key: str, body: bytes | dict | str | MessageBodyBase):
|
|
37
|
+
self.routing_key = routing_key
|
|
38
|
+
if isinstance(body, dict | BaseModel):
|
|
39
|
+
self.body: MessageBodyBase = self.message_body_cls.model_validate(body)
|
|
40
|
+
else: # str | bytes
|
|
41
|
+
self.body: MessageBodyBase = self.message_body_cls.model_validate_json(body)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConsumeMessageBase(_MessageBase):
|
|
45
|
+
"""
|
|
46
|
+
Base class for messages consumed from a DurableConsumer MessageProcessor. Each instance
|
|
47
|
+
correlates with a single consumed message. The body of the message is validated against the
|
|
48
|
+
message_body_cls class attribute.
|
|
49
|
+
>>> class ObjectBucketBody(MessageBodyBase):
|
|
50
|
+
>>> objectName: str
|
|
51
|
+
>>> bucket: str
|
|
52
|
+
>>>
|
|
53
|
+
>>> class InboundMessage(ConsumeMessageBase):
|
|
54
|
+
>>> message_body_cls: Type[ObjectBucketBody] = ObjectBucketBody # Validate the body with ObjectBucketBody
|
|
55
|
+
>>>
|
|
56
|
+
>>> queue = Queue("my_queue")
|
|
57
|
+
>>> with DurableConsumer(consume_queue=queue) as consumer:
|
|
58
|
+
>>> consumer.consume(lambda ch, method, properties, body: InboundMessage(method, properties, body))
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
method: pika.spec.Basic.Deliver,
|
|
64
|
+
properties: pika.spec.BasicProperties,
|
|
65
|
+
body: bytes | dict | str | MessageBodyBase,
|
|
66
|
+
):
|
|
67
|
+
super().__init__(method.routing_key, body)
|
|
68
|
+
self.method = method
|
|
69
|
+
self.properties = properties
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def delivery_tag(self):
|
|
73
|
+
return self.method.delivery_tag
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def headers(self) -> dict | None:
|
|
77
|
+
return self.properties.headers
|
|
78
|
+
|
|
79
|
+
def __repr__(self):
|
|
80
|
+
return f"{self.__class__.__name__}(method={self.method!r}, properties={self.properties!r}, body={self.body!r})"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PublishMessageBase(_MessageBase):
|
|
84
|
+
"""
|
|
85
|
+
Base class for messages published to with a DurableProducer.
|
|
86
|
+
Each instance correlates with a single message to publish. The body of the message is validated
|
|
87
|
+
against the class defined in message_body_cls class attribute. Additional class attributes for
|
|
88
|
+
routing_key and headers are provided for customizing the message at the class level e.g. all
|
|
89
|
+
instances share a routing_key.
|
|
90
|
+
|
|
91
|
+
>>> class OutboundMessage(PublishMessageBase):
|
|
92
|
+
>>> message_body_cls: Type[ObjectBucketBody] = ObjectBucketBody
|
|
93
|
+
>>> default_routing_key: str = "outbound.m"
|
|
94
|
+
>>> headers: dict[str, str] = Field(default={"header1": "value1"})
|
|
95
|
+
>>>
|
|
96
|
+
>>>
|
|
97
|
+
>>> queue = Queue(name="test_queue")
|
|
98
|
+
>>> exchange = Exchange(name="test_exchange")
|
|
99
|
+
>>> queue_bindings = Binding(queue=queue, message=OutboundMessage)
|
|
100
|
+
>>> with DurableProducer(queue_bindings=queue_bindings, publish_exchange=exchange) as producer:
|
|
101
|
+
>>> body = {"objectName": "object", "bucket": "bucket"}
|
|
102
|
+
>>> producer.publish(OutboundMessage(body))
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
default_routing_key: str = "default.m"
|
|
106
|
+
headers: dict[str, str] | None = None
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
body: bytes | dict | str | MessageBodyBase,
|
|
111
|
+
):
|
|
112
|
+
super().__init__(self.default_routing_key, body)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def properties(self) -> pika.spec.BasicProperties:
|
|
116
|
+
return pika.BasicProperties(
|
|
117
|
+
content_type="text/plain",
|
|
118
|
+
priority=0,
|
|
119
|
+
delivery_mode=pika.DeliveryMode.Persistent,
|
|
120
|
+
content_encoding="UTF-8",
|
|
121
|
+
headers=self.headers,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def __repr__(self):
|
|
125
|
+
return f"{self.__class__.__name__}(body={self.body!r}) # {self.routing_key = }"
|