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 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})"
File without changes
@@ -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
@@ -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
@@ -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 = }"