weenspace-queue 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.
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Callable, Dict, Optional
5
+
6
+ from weenspace_queue.base import Message, QueueEngine, QueueSpecification, TopicSpecification
7
+ from weenspace_queue.constants import (
8
+ ALLOW_SNS_SEND_SID_PREFIX,
9
+ AWS_SOURCE_ARN_CONDITION,
10
+ DEAD_LETTER_TARGET_ARN_KEY,
11
+ DEFAULT_AWS_REGION,
12
+ DEFAULT_SQS_MAX_MESSAGES,
13
+ DEFAULT_SQS_RETENTION_SECONDS,
14
+ DEFAULT_SQS_WAIT_TIME_SECONDS,
15
+ IAM_POLICY_VERSION,
16
+ MAX_RECEIVE_COUNT_KEY,
17
+ SNS_FILTER_POLICY_SCOPE_MESSAGE_ATTRIBUTES,
18
+ SNS_PROTOCOL_SQS,
19
+ SNS_SERVICE_PRINCIPAL,
20
+ SQS_FIFO_SUFFIX,
21
+ SQS_POLICY_ATTRIBUTE,
22
+ SQS_QUEUE_ARN_ATTRIBUTE,
23
+ SQS_RECEIPT_HANDLE_ATTR,
24
+ SQS_REDRIVE_POLICY_ATTRIBUTE,
25
+ SQS_RETENTION_ATTRIBUTE,
26
+ SQS_SEND_MESSAGE_ACTION,
27
+ SQS_VISIBILITY_ATTRIBUTE,
28
+ )
29
+ from weenspace_queue.utils import (
30
+ decode_body,
31
+ extract_sqs_body,
32
+ inject_aws_routing_attrs,
33
+ is_fifo_queue,
34
+ is_sns_destination,
35
+ optional_queue_name_from_url,
36
+ rmq_pattern_to_aws_sns,
37
+ )
38
+
39
+
40
+ class AwsEngine(QueueEngine):
41
+ def __init__(self, **kwargs: Any) -> None:
42
+ try:
43
+ import boto3
44
+ except ImportError as exc:
45
+ raise ImportError(
46
+ 'AWS provider requires boto3. Install with: pip install "weenspace-mq[aws]"'
47
+ ) from exc
48
+
49
+ self._running = False
50
+ self.session = boto3.Session(
51
+ region_name=kwargs.get("region_name", DEFAULT_AWS_REGION),
52
+ aws_access_key_id=kwargs.get("aws_access_key_id"),
53
+ aws_secret_access_key=kwargs.get("aws_secret_access_key"),
54
+ aws_session_token=kwargs.get("aws_session_token"),
55
+ profile_name=kwargs.get("profile_name"),
56
+ )
57
+ client_kwargs = {}
58
+ if kwargs.get("endpoint_url"):
59
+ client_kwargs["endpoint_url"] = kwargs["endpoint_url"]
60
+ self.sqs = self.session.client("sqs", **client_kwargs)
61
+ self.sns = self.session.client("sns", **client_kwargs)
62
+ self._wait_time_seconds = int(
63
+ kwargs.get("wait_time_seconds", DEFAULT_SQS_WAIT_TIME_SECONDS)
64
+ )
65
+ self._max_messages = int(
66
+ kwargs.get("max_number_of_messages", DEFAULT_SQS_MAX_MESSAGES)
67
+ )
68
+
69
+ def declare_queue(self, spec: QueueSpecification) -> str:
70
+ attributes: Dict[str, str] = {}
71
+ if spec.dead_letter_target:
72
+ attributes[SQS_REDRIVE_POLICY_ATTRIBUTE] = json.dumps(
73
+ {
74
+ DEAD_LETTER_TARGET_ARN_KEY: self._as_queue_arn(
75
+ spec.dead_letter_target
76
+ ),
77
+ MAX_RECEIVE_COUNT_KEY: str(spec.max_receive_count),
78
+ }
79
+ )
80
+ if spec.is_durable:
81
+ attributes[SQS_RETENTION_ATTRIBUTE] = DEFAULT_SQS_RETENTION_SECONDS
82
+ if spec.visibility_timeout_seconds is not None:
83
+ attributes[SQS_VISIBILITY_ATTRIBUTE] = str(spec.visibility_timeout_seconds)
84
+
85
+ fifo = spec.name.endswith(SQS_FIFO_SUFFIX)
86
+ create_kwargs: Dict[str, Any] = {"QueueName": spec.name}
87
+ if attributes:
88
+ create_kwargs["Attributes"] = attributes
89
+ if fifo:
90
+ create_kwargs.setdefault("Attributes", {})["FifoQueue"] = "true"
91
+ create_kwargs["Attributes"]["ContentBasedDeduplication"] = "true"
92
+
93
+ response = self.sqs.create_queue(**create_kwargs)
94
+ return response["QueueUrl"]
95
+
96
+ def declare_topic(self, spec: TopicSpecification) -> str:
97
+ create_kwargs: Dict[str, Any] = {"Name": spec.name}
98
+ if spec.name.endswith(SQS_FIFO_SUFFIX):
99
+ create_kwargs["Attributes"] = {"FifoTopic": "true"}
100
+ response = self.sns.create_topic(**create_kwargs)
101
+ return response["TopicArn"]
102
+
103
+ def bind_pattern(self, queue_id: str, topic_id: str, pattern: str) -> None:
104
+ queue_url = self._as_queue_url(queue_id)
105
+ queue_arn = self._as_queue_arn(queue_url)
106
+ policy = rmq_pattern_to_aws_sns(pattern)
107
+ subscribe_kwargs: Dict[str, Any] = {
108
+ "TopicArn": topic_id,
109
+ "Protocol": SNS_PROTOCOL_SQS,
110
+ "Endpoint": queue_arn,
111
+ }
112
+ attributes: Dict[str, str] = {}
113
+ if policy:
114
+ attributes["FilterPolicy"] = json.dumps(policy)
115
+ attributes["FilterPolicyScope"] = SNS_FILTER_POLICY_SCOPE_MESSAGE_ATTRIBUTES
116
+ if attributes:
117
+ subscribe_kwargs["Attributes"] = attributes
118
+ self.sns.subscribe(**subscribe_kwargs)
119
+ self._allow_sns_to_sqs(queue_url, queue_arn, topic_id)
120
+
121
+ def publish(self, destination: str, message: Message) -> None:
122
+ body_str = decode_body(message.body)
123
+ msg_attrs = dict(inject_aws_routing_attrs(message.routing_key))
124
+ extra_attrs = message.attributes or {}
125
+ for key, value in extra_attrs.items():
126
+ if key in msg_attrs or not isinstance(value, (str, int)):
127
+ continue
128
+ msg_attrs[key] = {
129
+ "DataType": "String",
130
+ "StringValue": str(value),
131
+ }
132
+
133
+ if is_sns_destination(destination):
134
+ publish_kwargs: Dict[str, Any] = {
135
+ "TopicArn": destination,
136
+ "Message": body_str,
137
+ }
138
+ if msg_attrs:
139
+ publish_kwargs["MessageAttributes"] = msg_attrs
140
+ group_id = extra_attrs.get("message_group_id") or message.routing_key
141
+ if destination.endswith(SQS_FIFO_SUFFIX) and group_id:
142
+ publish_kwargs["MessageGroupId"] = str(group_id)
143
+ dedup = extra_attrs.get("message_deduplication_id")
144
+ if dedup:
145
+ publish_kwargs["MessageDeduplicationId"] = str(dedup)
146
+ self.sns.publish(**publish_kwargs)
147
+ return
148
+
149
+ queue_url = self._as_queue_url(destination)
150
+ send_kwargs: Dict[str, Any] = {
151
+ "QueueUrl": queue_url,
152
+ "MessageBody": body_str,
153
+ }
154
+ if msg_attrs:
155
+ send_kwargs["MessageAttributes"] = msg_attrs
156
+ if is_fifo_queue(queue_url):
157
+ send_kwargs["MessageGroupId"] = str(
158
+ extra_attrs.get("message_group_id") or message.routing_key or "default"
159
+ )
160
+ dedup = extra_attrs.get("message_deduplication_id")
161
+ if dedup:
162
+ send_kwargs["MessageDeduplicationId"] = str(dedup)
163
+ self.sqs.send_message(**send_kwargs)
164
+
165
+ def consume(self, queue_id: str, handler: Callable[[Message], None]) -> None:
166
+ queue_url = self._as_queue_url(queue_id)
167
+ self._running = True
168
+ while self._running:
169
+ response = self.sqs.receive_message(
170
+ QueueUrl=queue_url,
171
+ MaxNumberOfMessages=self._max_messages,
172
+ WaitTimeSeconds=self._wait_time_seconds,
173
+ MessageAttributeNames=["All"],
174
+ AttributeNames=["All"],
175
+ )
176
+ for sqs_msg in response.get("Messages", []):
177
+ if not self._running:
178
+ break
179
+ handler(self._to_message(queue_url, sqs_msg))
180
+
181
+ def stop(self) -> None:
182
+ self._running = False
183
+
184
+ def close(self) -> None:
185
+ self.stop()
186
+
187
+ def _to_message(self, queue_url: str, sqs_msg: Dict[str, Any]) -> Message:
188
+ body, routing_key, attributes = extract_sqs_body(sqs_msg)
189
+ receipt = sqs_msg[SQS_RECEIPT_HANDLE_ATTR]
190
+ attributes[SQS_RECEIPT_HANDLE_ATTR] = receipt
191
+
192
+ def accept(handle: str = receipt) -> None:
193
+ self.sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=handle)
194
+
195
+ def requeue(handle: str = receipt) -> None:
196
+ self.sqs.change_message_visibility(
197
+ QueueUrl=queue_url, ReceiptHandle=handle, VisibilityTimeout=0
198
+ )
199
+
200
+ def reject(handle: str = receipt) -> None:
201
+ # Let the redrive policy / visibility timeout move the message toward the DLQ.
202
+ self.sqs.change_message_visibility(
203
+ QueueUrl=queue_url, ReceiptHandle=handle, VisibilityTimeout=0
204
+ )
205
+
206
+ return Message(
207
+ body=body,
208
+ routing_key=routing_key,
209
+ attributes=attributes,
210
+ accept=accept,
211
+ reject=reject,
212
+ requeue=requeue,
213
+ )
214
+
215
+ def _as_queue_url(self, queue_id: str) -> str:
216
+ if queue_id.startswith("https://") or queue_id.startswith("http://"):
217
+ return queue_id
218
+ if SQS_ARN_MARKER in queue_id:
219
+ name = queue_id.rsplit(":", 1)[-1]
220
+ return self.sqs.get_queue_url(QueueName=name)["QueueUrl"]
221
+ return self.sqs.get_queue_url(QueueName=queue_id)["QueueUrl"]
222
+
223
+ def _as_queue_arn(self, queue_id: str) -> str:
224
+ if SQS_ARN_MARKER in queue_id:
225
+ return queue_id
226
+ queue_url = self._as_queue_url(queue_id)
227
+ attrs = self.sqs.get_queue_attributes(
228
+ QueueUrl=queue_url, AttributeNames=[SQS_QUEUE_ARN_ATTRIBUTE]
229
+ )
230
+ return attrs["Attributes"][SQS_QUEUE_ARN_ATTRIBUTE]
231
+
232
+ def _allow_sns_to_sqs(self, queue_url: str, queue_arn: str, topic_arn: str) -> None:
233
+ try:
234
+ current = self.sqs.get_queue_attributes(
235
+ QueueUrl=queue_url, AttributeNames=[SQS_POLICY_ATTRIBUTE]
236
+ )
237
+ policy = json.loads(current.get("Attributes", {}).get(SQS_POLICY_ATTRIBUTE) or "{}")
238
+ except Exception:
239
+ policy = {}
240
+ if not policy:
241
+ policy = {"Version": IAM_POLICY_VERSION, "Statement": []}
242
+ statements = policy.setdefault("Statement", [])
243
+ sid = f"{ALLOW_SNS_SEND_SID_PREFIX}{abs(hash(topic_arn))}"
244
+ statement = {
245
+ "Sid": sid,
246
+ "Effect": "Allow",
247
+ "Principal": {"Service": SNS_SERVICE_PRINCIPAL},
248
+ "Action": SQS_SEND_MESSAGE_ACTION,
249
+ "Resource": queue_arn,
250
+ "Condition": {"ArnEquals": {AWS_SOURCE_ARN_CONDITION: topic_arn}},
251
+ }
252
+ statements = [
253
+ item
254
+ for item in statements
255
+ if not (
256
+ item.get("Action") == SQS_SEND_MESSAGE_ACTION
257
+ and item.get("Condition", {})
258
+ .get("ArnEquals", {})
259
+ .get(AWS_SOURCE_ARN_CONDITION)
260
+ == topic_arn
261
+ )
262
+ ]
263
+ statements.append(statement)
264
+ policy["Statement"] = statements
265
+ self.sqs.set_queue_attributes(
266
+ QueueUrl=queue_url,
267
+ Attributes={SQS_POLICY_ATTRIBUTE: json.dumps(policy)},
268
+ )
269
+
270
+ def resolve_queue_name(self, queue_id: str) -> Optional[str]:
271
+ if queue_id.startswith("https://"):
272
+ return optional_queue_name_from_url(queue_id)
273
+ return queue_id
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Optional
4
+
5
+ from weenspace_queue import (
6
+ AMQPMessagingHandler,
7
+ ClassicQueueSpecification,
8
+ Environment,
9
+ Event,
10
+ ExchangeSpecification,
11
+ ExchangeToQueueBindingSpecification,
12
+ ExchangeType,
13
+ Message as ProtonMessage,
14
+ QuorumQueueSpecification,
15
+ StreamSpecification,
16
+ )
17
+ from weenspace_queue.delivery_context import DeliveryContext
18
+
19
+ from weenspace_queue.base import Message, QueueEngine, QueueSpecification, TopicSpecification
20
+ from weenspace_queue.constants import (
21
+ DEFAULT_RABBITMQ_URI,
22
+ ExchangeKind,
23
+ QueueKind,
24
+ )
25
+ from weenspace_queue.utils import (
26
+ encode_body,
27
+ rabbitmq_exchange_address,
28
+ rabbitmq_queue_address,
29
+ rabbitmq_resource_name,
30
+ )
31
+
32
+ _EXCHANGE_KIND_MAP = {
33
+ ExchangeKind.DIRECT: ExchangeType.DIRECT,
34
+ ExchangeKind.TOPIC: ExchangeType.TOPIC,
35
+ ExchangeKind.FANOUT: ExchangeType.FANOUT,
36
+ ExchangeKind.HEADERS: ExchangeType.HEADERS,
37
+ }
38
+
39
+
40
+ class _CallbackHandler(AMQPMessagingHandler):
41
+ def __init__(self, handler: Callable[[Message], None]) -> None:
42
+ super().__init__(auto_accept=False, auto_settle=True)
43
+ self._handler = handler
44
+
45
+ def on_amqp_message(self, event: Event) -> None:
46
+ proton_msg = event.message
47
+ routing_key = proton_msg.subject or ""
48
+ attributes = dict(proton_msg.properties or {})
49
+ if proton_msg.address:
50
+ attributes["address"] = proton_msg.address
51
+ if proton_msg.correlation_id is not None:
52
+ attributes["correlation_id"] = proton_msg.correlation_id
53
+ if proton_msg.reply_to:
54
+ attributes["reply_to"] = proton_msg.reply_to
55
+
56
+ context = DeliveryContext()
57
+
58
+ def accept(evt: Event = event) -> None:
59
+ context.accept(evt)
60
+
61
+ def reject(evt: Event = event) -> None:
62
+ context.discard(evt)
63
+
64
+ def requeue(evt: Event = event) -> None:
65
+ context.requeue(evt)
66
+
67
+ self._handler(
68
+ Message(
69
+ body=encode_body(proton_msg.body),
70
+ routing_key=routing_key,
71
+ attributes=attributes,
72
+ accept=accept,
73
+ reject=reject,
74
+ requeue=requeue,
75
+ )
76
+ )
77
+
78
+
79
+ class RabbitMqEngine(QueueEngine):
80
+ def __init__(self, **kwargs: Any) -> None:
81
+ uri = kwargs.get("uri")
82
+ uris = kwargs.get("uris")
83
+ if uri is None and uris is None:
84
+ uri = DEFAULT_RABBITMQ_URI
85
+ env_kwargs: dict[str, Any] = {"uri": uri, "uris": uris}
86
+ for key in ("ssl_context", "oauth2_options", "recovery_configuration"):
87
+ if kwargs.get(key) is not None:
88
+ env_kwargs[key] = kwargs[key]
89
+ self._env = Environment(**env_kwargs)
90
+ self._conn = self._env.connection()
91
+ self._conn.dial()
92
+ self._mgmt = self._conn.management()
93
+ self._consumer: Optional[Any] = None
94
+
95
+ def declare_queue(self, spec: QueueSpecification) -> str:
96
+ if spec.kind == QueueKind.STREAM:
97
+ self._mgmt.declare_queue(
98
+ StreamSpecification(
99
+ name=spec.name,
100
+ **{
101
+ key: value
102
+ for key, value in spec.extra.items()
103
+ if key in StreamSpecification.__dataclass_fields__
104
+ },
105
+ )
106
+ )
107
+ return rabbitmq_queue_address(spec.name)
108
+
109
+ if spec.kind == QueueKind.QUORUM:
110
+ self._mgmt.declare_queue(
111
+ QuorumQueueSpecification(
112
+ name=spec.name,
113
+ dead_letter_exchange=spec.dead_letter_target,
114
+ dead_letter_routing_key=spec.dead_letter_routing_key,
115
+ deliver_limit=spec.max_receive_count,
116
+ **{
117
+ key: value
118
+ for key, value in spec.extra.items()
119
+ if key in QuorumQueueSpecification.__dataclass_fields__
120
+ },
121
+ )
122
+ )
123
+ return rabbitmq_queue_address(spec.name)
124
+
125
+ self._mgmt.declare_queue(
126
+ ClassicQueueSpecification(
127
+ name=spec.name,
128
+ is_durable=spec.is_durable,
129
+ dead_letter_exchange=spec.dead_letter_target,
130
+ dead_letter_routing_key=spec.dead_letter_routing_key,
131
+ **{
132
+ key: value
133
+ for key, value in spec.extra.items()
134
+ if key in ClassicQueueSpecification.__dataclass_fields__
135
+ },
136
+ )
137
+ )
138
+ return rabbitmq_queue_address(spec.name)
139
+
140
+ def declare_topic(self, spec: TopicSpecification) -> str:
141
+ self._mgmt.declare_exchange(
142
+ ExchangeSpecification(
143
+ name=spec.name,
144
+ exchange_type=_EXCHANGE_KIND_MAP.get(spec.kind, ExchangeType.TOPIC),
145
+ is_durable=spec.is_durable,
146
+ )
147
+ )
148
+ return spec.name
149
+
150
+ def bind_pattern(self, queue_id: str, topic_id: str, pattern: str) -> None:
151
+ self._mgmt.bind(
152
+ ExchangeToQueueBindingSpecification(
153
+ source_exchange=rabbitmq_resource_name(topic_id),
154
+ destination_queue=rabbitmq_resource_name(queue_id),
155
+ binding_key=pattern,
156
+ )
157
+ )
158
+
159
+ def publish(self, destination: str, message: Message) -> None:
160
+ address = self._publish_address(destination, message.routing_key)
161
+ publisher = self._conn.publisher(address)
162
+ try:
163
+ proton_msg = ProtonMessage(body=encode_body(message.body))
164
+ proton_msg.inferred = True
165
+ if message.routing_key:
166
+ proton_msg.subject = message.routing_key
167
+ correlation_id = (message.attributes or {}).get("correlation_id")
168
+ if correlation_id is not None:
169
+ proton_msg.correlation_id = correlation_id
170
+ reply_to = (message.attributes or {}).get("reply_to")
171
+ if reply_to:
172
+ proton_msg.reply_to = reply_to
173
+ if message.attributes:
174
+ proton_msg.properties = {
175
+ key: value
176
+ for key, value in message.attributes.items()
177
+ if key not in {"correlation_id", "reply_to"}
178
+ and isinstance(value, (str, int, float, bool))
179
+ }
180
+ publisher.publish(proton_msg)
181
+ finally:
182
+ publisher.close()
183
+
184
+ def consume(self, queue_id: str, handler: Callable[[Message], None]) -> None:
185
+ destination = rabbitmq_queue_address(queue_id)
186
+ wrapped = _CallbackHandler(handler)
187
+ self._consumer = self._conn.consumer(destination, message_handler=wrapped)
188
+ self._consumer.run()
189
+
190
+ def stop(self) -> None:
191
+ if self._consumer is not None:
192
+ self._consumer.stop()
193
+
194
+ def close(self) -> None:
195
+ self.stop()
196
+ self._conn.close()
197
+
198
+ def _publish_address(self, destination: str, routing_key: str) -> str:
199
+ if destination.startswith("/queues/"):
200
+ return destination
201
+ if destination.startswith("/exchanges/"):
202
+ return rabbitmq_exchange_address(destination, routing_key)
203
+ if routing_key:
204
+ return rabbitmq_exchange_address(destination, routing_key)
205
+ return rabbitmq_queue_address(destination)