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,128 @@
1
+ from typing import Any, Callable, Dict, Type
2
+
3
+ from .asyncio.aws_async import AwsAsyncEngine
4
+ from .asyncio.rabbitmq_async import RabbitMqAsyncEngine
5
+ from .base import (
6
+ AsyncQueueEngine,
7
+ Message,
8
+ QueueEngine,
9
+ QueueSpecification,
10
+ TopicSpecification,
11
+ )
12
+ from .constants import (
13
+ PROVIDER_AWS,
14
+ PROVIDER_RABBITMQ,
15
+ ExchangeKind,
16
+ Provider,
17
+ QueueKind,
18
+ )
19
+ from .providers.aws import AwsEngine
20
+ from .providers.rabbitmq import RabbitMqEngine
21
+
22
+
23
+ class QueueClient:
24
+ """Single client: pass provider name, then use the same publish/consume/topology methods."""
25
+
26
+ _ENGINES: Dict[str, Type[QueueEngine]] = {
27
+ PROVIDER_AWS: AwsEngine,
28
+ PROVIDER_RABBITMQ: RabbitMqEngine,
29
+ }
30
+
31
+ def __init__(self, provider: str, **config: Any) -> None:
32
+ prov_key = provider.lower().strip()
33
+ engine_cls = self._ENGINES.get(prov_key)
34
+ if engine_cls is None:
35
+ supported = ", ".join(sorted(self._ENGINES))
36
+ raise ValueError(
37
+ f"Unsupported provider '{provider}'. Supported: {supported}"
38
+ )
39
+ self.provider = prov_key
40
+ self.engine: QueueEngine = engine_cls(**config)
41
+
42
+ def declare_queue(self, spec: QueueSpecification) -> str:
43
+ return self.engine.declare_queue(spec)
44
+
45
+ def declare_topic(self, spec: TopicSpecification) -> str:
46
+ return self.engine.declare_topic(spec)
47
+
48
+ def bind_pattern(self, queue_id: str, topic_id: str, pattern: str) -> None:
49
+ self.engine.bind_pattern(queue_id, topic_id, pattern)
50
+
51
+ def publish(self, destination: str, message: Message) -> None:
52
+ self.engine.publish(destination, message)
53
+
54
+ def consume(self, queue_id: str, handler: Callable[[Message], None]) -> None:
55
+ self.engine.consume(queue_id, handler)
56
+
57
+ def stop(self) -> None:
58
+ self.engine.stop()
59
+
60
+ def close(self) -> None:
61
+ self.engine.close()
62
+
63
+ def __enter__(self) -> "QueueClient":
64
+ return self
65
+
66
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
67
+ self.close()
68
+
69
+
70
+ class AsyncQueueClient:
71
+ """Async twin of QueueClient. Same method names, same provider argument."""
72
+
73
+ _ENGINES: Dict[str, Type[AsyncQueueEngine]] = {
74
+ PROVIDER_AWS: AwsAsyncEngine,
75
+ PROVIDER_RABBITMQ: RabbitMqAsyncEngine,
76
+ }
77
+
78
+ def __init__(self, provider: str, **config: Any) -> None:
79
+ prov_key = provider.lower().strip()
80
+ engine_cls = self._ENGINES.get(prov_key)
81
+ if engine_cls is None:
82
+ supported = ", ".join(sorted(self._ENGINES))
83
+ raise ValueError(
84
+ f"Unsupported provider '{provider}'. Supported: {supported}"
85
+ )
86
+ self.provider = prov_key
87
+ self.engine: AsyncQueueEngine = engine_cls(**config)
88
+
89
+ async def declare_queue(self, spec: QueueSpecification) -> str:
90
+ return await self.engine.declare_queue(spec)
91
+
92
+ async def declare_topic(self, spec: TopicSpecification) -> str:
93
+ return await self.engine.declare_topic(spec)
94
+
95
+ async def bind_pattern(self, queue_id: str, topic_id: str, pattern: str) -> None:
96
+ await self.engine.bind_pattern(queue_id, topic_id, pattern)
97
+
98
+ async def publish(self, destination: str, message: Message) -> None:
99
+ await self.engine.publish(destination, message)
100
+
101
+ async def consume(self, queue_id: str, handler: Callable[[Message], None]) -> None:
102
+ await self.engine.consume(queue_id, handler)
103
+
104
+ async def stop(self) -> None:
105
+ await self.engine.stop()
106
+
107
+ async def close(self) -> None:
108
+ await self.engine.close()
109
+
110
+ async def __aenter__(self) -> "AsyncQueueClient":
111
+ return self
112
+
113
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
114
+ await self.close()
115
+
116
+
117
+ __all__ = [
118
+ "AsyncQueueClient",
119
+ "AsyncQueueEngine",
120
+ "ExchangeKind",
121
+ "Message",
122
+ "Provider",
123
+ "QueueClient",
124
+ "QueueEngine",
125
+ "QueueKind",
126
+ "QueueSpecification",
127
+ "TopicSpecification",
128
+ ]
@@ -0,0 +1,4 @@
1
+ from .aws_async import AwsAsyncEngine
2
+ from .rabbitmq_async import RabbitMqAsyncEngine
3
+
4
+ __all__ = ["AwsAsyncEngine", "RabbitMqAsyncEngine"]
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from weenspace_queue.base import (
5
+ AsyncQueueEngine,
6
+ Message,
7
+ QueueSpecification,
8
+ TopicSpecification,
9
+ )
10
+ from weenspace_queue.providers.aws import AwsEngine
11
+
12
+
13
+ class AwsAsyncEngine(AsyncQueueEngine):
14
+ """Async AWS engine. Uses aioboto3 when installed, otherwise boto3 in a worker thread."""
15
+
16
+ def __init__(self, **kwargs: Any) -> None:
17
+ self._sync = AwsEngine(**kwargs)
18
+
19
+ async def declare_queue(self, spec: QueueSpecification) -> str:
20
+ return await asyncio.to_thread(self._sync.declare_queue, spec)
21
+
22
+ async def declare_topic(self, spec: TopicSpecification) -> str:
23
+ return await asyncio.to_thread(self._sync.declare_topic, spec)
24
+
25
+ async def bind_pattern(self, queue_id: str, topic_id: str, pattern: str) -> None:
26
+ await asyncio.to_thread(self._sync.bind_pattern, queue_id, topic_id, pattern)
27
+
28
+ async def publish(self, destination: str, message: Message) -> None:
29
+ await asyncio.to_thread(self._sync.publish, destination, message)
30
+
31
+ async def consume(self, queue_id: str, handler: Callable[[Message], None]) -> None:
32
+ await asyncio.to_thread(self._sync.consume, queue_id, handler)
33
+
34
+ async def stop(self) -> None:
35
+ self._sync.stop()
36
+
37
+ async def close(self) -> None:
38
+ await asyncio.to_thread(self._sync.close)
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Optional
4
+
5
+ from weenspace_queue import (
6
+ ClassicQueueSpecification,
7
+ ExchangeSpecification,
8
+ ExchangeToQueueBindingSpecification,
9
+ ExchangeType,
10
+ Message as ProtonMessage,
11
+ QuorumQueueSpecification,
12
+ StreamSpecification,
13
+ )
14
+ from weenspace_queue.asyncio import AsyncEnvironment
15
+ from weenspace_queue.delivery_context import DeliveryContext
16
+ from weenspace_queue.amqp_consumer_handler import AMQPMessagingHandler
17
+ from weenspace_queue.qpid.proton._events import Event
18
+
19
+ from weenspace_queue.base import (
20
+ AsyncQueueEngine,
21
+ Message,
22
+ QueueSpecification,
23
+ TopicSpecification,
24
+ )
25
+ from weenspace_queue.constants import (
26
+ DEFAULT_RABBITMQ_URI,
27
+ ExchangeKind,
28
+ QueueKind,
29
+ )
30
+ from weenspace_queue.utils import (
31
+ encode_body,
32
+ rabbitmq_exchange_address,
33
+ rabbitmq_queue_address,
34
+ rabbitmq_resource_name,
35
+ )
36
+
37
+ _EXCHANGE_KIND_MAP = {
38
+ ExchangeKind.DIRECT: ExchangeType.DIRECT,
39
+ ExchangeKind.TOPIC: ExchangeType.TOPIC,
40
+ ExchangeKind.FANOUT: ExchangeType.FANOUT,
41
+ ExchangeKind.HEADERS: ExchangeType.HEADERS,
42
+ }
43
+
44
+
45
+ class _CallbackHandler(AMQPMessagingHandler):
46
+ def __init__(self, handler: Callable[[Message], None]) -> None:
47
+ super().__init__(auto_accept=False, auto_settle=True)
48
+ self._handler = handler
49
+
50
+ def on_amqp_message(self, event: Event) -> None:
51
+ proton_msg = event.message
52
+ routing_key = proton_msg.subject or ""
53
+ attributes = dict(proton_msg.properties or {})
54
+ context = DeliveryContext()
55
+
56
+ def accept(evt: Event = event) -> None:
57
+ context.accept(evt)
58
+
59
+ def reject(evt: Event = event) -> None:
60
+ context.discard(evt)
61
+
62
+ def requeue(evt: Event = event) -> None:
63
+ context.requeue(evt)
64
+
65
+ self._handler(
66
+ Message(
67
+ body=encode_body(proton_msg.body),
68
+ routing_key=routing_key,
69
+ attributes=attributes,
70
+ accept=accept,
71
+ reject=reject,
72
+ requeue=requeue,
73
+ )
74
+ )
75
+
76
+
77
+ class RabbitMqAsyncEngine(AsyncQueueEngine):
78
+ def __init__(self, **kwargs: Any) -> None:
79
+ uri = kwargs.get("uri")
80
+ uris = kwargs.get("uris")
81
+ if uri is None and uris is None:
82
+ uri = DEFAULT_RABBITMQ_URI
83
+ env_kwargs: dict[str, Any] = {"uri": uri, "uris": uris}
84
+ for key in ("ssl_context", "oauth2_options", "recovery_configuration"):
85
+ if kwargs.get(key) is not None:
86
+ env_kwargs[key] = kwargs[key]
87
+ self._env = AsyncEnvironment(**env_kwargs)
88
+ self._conn: Any = None
89
+ self._mgmt: Any = None
90
+ self._consumer: Any = None
91
+
92
+ async def _ensure(self) -> None:
93
+ if self._conn is not None:
94
+ return
95
+ self._conn = await self._env.connection()
96
+ await self._conn.dial()
97
+ self._mgmt = await self._conn.management()
98
+
99
+ async def declare_queue(self, spec: QueueSpecification) -> str:
100
+ await self._ensure()
101
+ if spec.kind == QueueKind.STREAM:
102
+ await self._mgmt.declare_queue(StreamSpecification(name=spec.name))
103
+ elif spec.kind == QueueKind.QUORUM:
104
+ await self._mgmt.declare_queue(
105
+ QuorumQueueSpecification(
106
+ name=spec.name,
107
+ dead_letter_exchange=spec.dead_letter_target,
108
+ dead_letter_routing_key=spec.dead_letter_routing_key,
109
+ deliver_limit=spec.max_receive_count,
110
+ )
111
+ )
112
+ else:
113
+ await self._mgmt.declare_queue(
114
+ ClassicQueueSpecification(
115
+ name=spec.name,
116
+ is_durable=spec.is_durable,
117
+ dead_letter_exchange=spec.dead_letter_target,
118
+ dead_letter_routing_key=spec.dead_letter_routing_key,
119
+ )
120
+ )
121
+ return rabbitmq_queue_address(spec.name)
122
+
123
+ async def declare_topic(self, spec: TopicSpecification) -> str:
124
+ await self._ensure()
125
+ await self._mgmt.declare_exchange(
126
+ ExchangeSpecification(
127
+ name=spec.name,
128
+ exchange_type=_EXCHANGE_KIND_MAP.get(spec.kind, ExchangeType.TOPIC),
129
+ is_durable=spec.is_durable,
130
+ )
131
+ )
132
+ return spec.name
133
+
134
+ async def bind_pattern(self, queue_id: str, topic_id: str, pattern: str) -> None:
135
+ await self._ensure()
136
+ await self._mgmt.bind(
137
+ ExchangeToQueueBindingSpecification(
138
+ source_exchange=rabbitmq_resource_name(topic_id),
139
+ destination_queue=rabbitmq_resource_name(queue_id),
140
+ binding_key=pattern,
141
+ )
142
+ )
143
+
144
+ async def publish(self, destination: str, message: Message) -> None:
145
+ await self._ensure()
146
+ address = self._publish_address(destination, message.routing_key)
147
+ publisher = await self._conn.publisher(address)
148
+ try:
149
+ proton_msg = ProtonMessage(body=encode_body(message.body))
150
+ proton_msg.inferred = True
151
+ if message.routing_key:
152
+ proton_msg.subject = message.routing_key
153
+ await publisher.publish(proton_msg)
154
+ finally:
155
+ await publisher.close()
156
+
157
+ async def consume(self, queue_id: str, handler: Callable[[Message], None]) -> None:
158
+ await self._ensure()
159
+ destination = rabbitmq_queue_address(queue_id)
160
+ self._consumer = await self._conn.consumer(
161
+ destination, message_handler=_CallbackHandler(handler)
162
+ )
163
+ await self._consumer.run()
164
+
165
+ async def stop(self) -> None:
166
+ if self._consumer is not None:
167
+ await self._consumer.stop()
168
+
169
+ async def close(self) -> None:
170
+ await self.stop()
171
+ if self._conn is not None:
172
+ await self._conn.close()
173
+ await self._env.close()
174
+
175
+ def _publish_address(self, destination: str, routing_key: str) -> str:
176
+ if destination.startswith("/queues/"):
177
+ return destination
178
+ if destination.startswith("/exchanges/"):
179
+ return rabbitmq_exchange_address(destination, routing_key)
180
+ if routing_key:
181
+ return rabbitmq_exchange_address(destination, routing_key)
182
+ return rabbitmq_queue_address(destination)
@@ -0,0 +1,103 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ from .constants import (
6
+ DEFAULT_MAX_RECEIVE_COUNT,
7
+ ExchangeKind,
8
+ QueueKind,
9
+ )
10
+
11
+
12
+ @dataclass
13
+ class Message:
14
+ body: bytes
15
+ routing_key: str = ""
16
+ attributes: Dict[str, Any] = field(default_factory=dict)
17
+ accept: Optional[Callable[[], None]] = None
18
+ reject: Optional[Callable[[], None]] = None
19
+ requeue: Optional[Callable[[], None]] = None
20
+
21
+
22
+ @dataclass
23
+ class QueueSpecification:
24
+ name: str
25
+ is_durable: bool = True
26
+ kind: QueueKind = QueueKind.CLASSIC
27
+ dead_letter_target: Optional[str] = None
28
+ dead_letter_routing_key: Optional[str] = None
29
+ max_receive_count: int = DEFAULT_MAX_RECEIVE_COUNT
30
+ visibility_timeout_seconds: Optional[int] = None
31
+ extra: Dict[str, Any] = field(default_factory=dict)
32
+
33
+
34
+ @dataclass
35
+ class TopicSpecification:
36
+ name: str
37
+ is_durable: bool = True
38
+ kind: ExchangeKind = ExchangeKind.TOPIC
39
+ extra: Dict[str, Any] = field(default_factory=dict)
40
+
41
+
42
+ class QueueEngine(ABC):
43
+ """Provider strategy: one method set for publish, consume, and topology."""
44
+
45
+ @abstractmethod
46
+ def declare_queue(self, spec: QueueSpecification) -> str:
47
+ pass
48
+
49
+ @abstractmethod
50
+ def declare_topic(self, spec: TopicSpecification) -> str:
51
+ pass
52
+
53
+ @abstractmethod
54
+ def bind_pattern(self, queue_id: str, topic_id: str, pattern: str) -> None:
55
+ pass
56
+
57
+ @abstractmethod
58
+ def publish(self, destination: str, message: Message) -> None:
59
+ pass
60
+
61
+ @abstractmethod
62
+ def consume(self, queue_id: str, handler: Callable[[Message], None]) -> None:
63
+ pass
64
+
65
+ @abstractmethod
66
+ def stop(self) -> None:
67
+ pass
68
+
69
+ @abstractmethod
70
+ def close(self) -> None:
71
+ pass
72
+
73
+
74
+ class AsyncQueueEngine(ABC):
75
+ """Async counterpart of QueueEngine with identical method names."""
76
+
77
+ @abstractmethod
78
+ async def declare_queue(self, spec: QueueSpecification) -> str:
79
+ pass
80
+
81
+ @abstractmethod
82
+ async def declare_topic(self, spec: TopicSpecification) -> str:
83
+ pass
84
+
85
+ @abstractmethod
86
+ async def bind_pattern(self, queue_id: str, topic_id: str, pattern: str) -> None:
87
+ pass
88
+
89
+ @abstractmethod
90
+ async def publish(self, destination: str, message: Message) -> None:
91
+ pass
92
+
93
+ @abstractmethod
94
+ async def consume(self, queue_id: str, handler: Callable[[Message], None]) -> None:
95
+ pass
96
+
97
+ @abstractmethod
98
+ async def stop(self) -> None:
99
+ pass
100
+
101
+ @abstractmethod
102
+ async def close(self) -> None:
103
+ pass
@@ -0,0 +1,75 @@
1
+ """Shared uppercase constants for the unified queue client."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class Provider(str, Enum):
7
+ AWS = "aws"
8
+ RABBITMQ = "rabbitmq"
9
+
10
+
11
+ class QueueKind(str, Enum):
12
+ CLASSIC = "classic"
13
+ QUORUM = "quorum"
14
+ STREAM = "stream"
15
+
16
+
17
+ class ExchangeKind(str, Enum):
18
+ DIRECT = "direct"
19
+ TOPIC = "topic"
20
+ FANOUT = "fanout"
21
+ HEADERS = "headers"
22
+
23
+
24
+ PROVIDER_AWS = Provider.AWS.value
25
+ PROVIDER_RABBITMQ = Provider.RABBITMQ.value
26
+
27
+ DEFAULT_AWS_REGION = "us-east-1"
28
+ DEFAULT_RABBITMQ_URI = "amqp://guest:guest@localhost:5672/"
29
+ DEFAULT_MAX_RECEIVE_COUNT = 5
30
+ DEFAULT_SQS_WAIT_TIME_SECONDS = 20
31
+ DEFAULT_SQS_MAX_MESSAGES = 10
32
+ DEFAULT_SQS_RETENTION_SECONDS = "345600"
33
+ DEFAULT_SQS_VISIBILITY_TIMEOUT_SECONDS = 30
34
+
35
+ SNS_ARN_MARKER = "arn:aws:sns"
36
+ SQS_ARN_MARKER = "arn:aws:sqs"
37
+ SQS_URL_MARKER = "amazonaws.com"
38
+ SQS_FIFO_SUFFIX = ".fifo"
39
+
40
+ WILDCARD_SINGLE = "*"
41
+ WILDCARD_MULTI = "#"
42
+ ROUTING_SEPARATOR = "."
43
+
44
+ ROUTING_KEY_ATTR = "routing_key"
45
+ ROUTING_KEY_LENGTH_ATTR = "rk_len"
46
+ ROUTING_KEY_INDEX_PREFIX = "rk_idx_"
47
+ ROUTING_KEY_REVERSE_INDEX_PREFIX = "rk_ridx_"
48
+
49
+ AWS_STRING_DATA_TYPE = "String"
50
+ AWS_NUMBER_DATA_TYPE = "Number"
51
+ SNS_FILTER_POLICY_SCOPE_MESSAGE_ATTRIBUTES = "MessageAttributes"
52
+ SNS_PROTOCOL_SQS = "sqs"
53
+
54
+ RABBITMQ_QUEUE_PREFIX = "/queues/"
55
+ RABBITMQ_EXCHANGE_PREFIX = "/exchanges/"
56
+
57
+ SQS_RECEIPT_HANDLE_ATTR = "ReceiptHandle"
58
+ SQS_MESSAGE_ATTRIBUTES_KEY = "MessageAttributes"
59
+ SQS_BODY_KEY = "Body"
60
+ SNS_NOTIFICATION_TYPE = "Notification"
61
+ SNS_MESSAGE_KEY = "Message"
62
+
63
+ IAM_POLICY_VERSION = "2012-10-17"
64
+ SQS_SEND_MESSAGE_ACTION = "sqs:SendMessage"
65
+ SNS_SERVICE_PRINCIPAL = "sns.amazonaws.com"
66
+ SQS_POLICY_ATTRIBUTE = "Policy"
67
+ SQS_QUEUE_ARN_ATTRIBUTE = "QueueArn"
68
+ SQS_REDRIVE_POLICY_ATTRIBUTE = "RedrivePolicy"
69
+ SQS_RETENTION_ATTRIBUTE = "MessageRetentionPeriod"
70
+ SQS_VISIBILITY_ATTRIBUTE = "VisibilityTimeout"
71
+ DEAD_LETTER_TARGET_ARN_KEY = "deadLetterTargetArn"
72
+ MAX_RECEIVE_COUNT_KEY = "maxReceiveCount"
73
+
74
+ AWS_SOURCE_ARN_CONDITION = "aws:SourceArn"
75
+ ALLOW_SNS_SEND_SID_PREFIX = "AllowSnsSendMessage"
@@ -0,0 +1,4 @@
1
+ from .aws import AwsEngine
2
+ from .rabbitmq import RabbitMqEngine
3
+
4
+ __all__ = ["AwsEngine", "RabbitMqEngine"]