ergon-framework-python 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.
- ergon/__init__.py +13 -0
- ergon/bootstrap/src/__project__/__init__.py +0 -0
- ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
- ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
- ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
- ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
- ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
- ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
- ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
- ergon/bootstrap/src/__project__/main.py +9 -0
- ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
- ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
- ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
- ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
- ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
- ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
- ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
- ergon/cli.py +174 -0
- ergon/connector/__init__.py +64 -0
- ergon/connector/connector.py +97 -0
- ergon/connector/excel/__init__.py +18 -0
- ergon/connector/excel/connector.py +175 -0
- ergon/connector/excel/models.py +24 -0
- ergon/connector/excel/service.py +98 -0
- ergon/connector/pipefy/__init__.py +21 -0
- ergon/connector/pipefy/async_connector.py +48 -0
- ergon/connector/pipefy/async_service.py +907 -0
- ergon/connector/pipefy/connector.py +36 -0
- ergon/connector/pipefy/models.py +48 -0
- ergon/connector/pipefy/service.py +1016 -0
- ergon/connector/pipefy/version.py +1 -0
- ergon/connector/postgres/__init__.py +11 -0
- ergon/connector/postgres/async_connector.py +119 -0
- ergon/connector/postgres/async_service.py +116 -0
- ergon/connector/postgres/models.py +34 -0
- ergon/connector/rabbitmq/__init__.py +25 -0
- ergon/connector/rabbitmq/async_connector.py +120 -0
- ergon/connector/rabbitmq/async_service.py +417 -0
- ergon/connector/rabbitmq/connector.py +54 -0
- ergon/connector/rabbitmq/helper.py +14 -0
- ergon/connector/rabbitmq/models.py +92 -0
- ergon/connector/rabbitmq/service.py +199 -0
- ergon/connector/sqs/__init__.py +15 -0
- ergon/connector/sqs/async_connector.py +120 -0
- ergon/connector/sqs/async_service.py +246 -0
- ergon/connector/sqs/connector.py +120 -0
- ergon/connector/sqs/models.py +36 -0
- ergon/connector/sqs/service.py +219 -0
- ergon/connector/transaction.py +14 -0
- ergon/py.typed +0 -0
- ergon/service/__init__.py +5 -0
- ergon/service/service.py +17 -0
- ergon/task/__init__.py +13 -0
- ergon/task/base.py +222 -0
- ergon/task/exceptions.py +217 -0
- ergon/task/helpers.py +691 -0
- ergon/task/manager.py +85 -0
- ergon/task/mixins/__init__.py +13 -0
- ergon/task/mixins/consumer.py +858 -0
- ergon/task/mixins/metrics.py +457 -0
- ergon/task/mixins/producer.py +486 -0
- ergon/task/policies.py +229 -0
- ergon/task/runner.py +386 -0
- ergon/task/utils.py +64 -0
- ergon/telemetry/__init__.py +7 -0
- ergon/telemetry/_resource.py +13 -0
- ergon/telemetry/logging.py +370 -0
- ergon/telemetry/metrics.py +101 -0
- ergon/telemetry/tracing.py +152 -0
- ergon/utils/__init__.py +5 -0
- ergon/utils/env.py +26 -0
- ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
- ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
- ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
- ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
- ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- ergon_framework_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import ssl as ssl_module
|
|
5
|
+
import time
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import pika
|
|
10
|
+
|
|
11
|
+
from .helper import headers_generator
|
|
12
|
+
from .models import RabbitmqClient, RabbitmqProducerMessage
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RabbitMQService:
|
|
18
|
+
def __init__(self, client: RabbitmqClient) -> None:
|
|
19
|
+
self.client = client
|
|
20
|
+
|
|
21
|
+
self._connection: Optional[pika.BlockingConnection] = None
|
|
22
|
+
self._channel: Any = None
|
|
23
|
+
self._connect()
|
|
24
|
+
|
|
25
|
+
# ---------- Connection / Channel ----------
|
|
26
|
+
|
|
27
|
+
def _connect(self) -> None:
|
|
28
|
+
"""Ensure an open connection and channel exist."""
|
|
29
|
+
if self._connection and self._connection.is_open:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
ssl_options = None
|
|
33
|
+
if self.client.ssl_enabled:
|
|
34
|
+
ctx = ssl_module.create_default_context()
|
|
35
|
+
if self.client.ssl_ca_certs:
|
|
36
|
+
ctx.load_verify_locations(self.client.ssl_ca_certs)
|
|
37
|
+
ssl_options = pika.SSLOptions(ctx)
|
|
38
|
+
|
|
39
|
+
params = pika.ConnectionParameters(
|
|
40
|
+
host=self.client.host,
|
|
41
|
+
port=self.client.port,
|
|
42
|
+
virtual_host=self.client.virtual_host or "/",
|
|
43
|
+
credentials=pika.PlainCredentials(self.client.username, self.client.password),
|
|
44
|
+
connection_attempts=self.client.connection_attempts,
|
|
45
|
+
socket_timeout=self.client.socket_timeout,
|
|
46
|
+
heartbeat=int(self.client.heartbeat) if self.client.heartbeat is not None else None,
|
|
47
|
+
blocked_connection_timeout=self.client.blocked_connection_timeout,
|
|
48
|
+
ssl_options=ssl_options,
|
|
49
|
+
)
|
|
50
|
+
self._connection = pika.BlockingConnection(params)
|
|
51
|
+
self._channel = self._connection.channel()
|
|
52
|
+
|
|
53
|
+
self._channel.queue_declare(queue=self.client.queue_name, durable=True)
|
|
54
|
+
self._channel.basic_qos(prefetch_count=self.client.prefetch_count)
|
|
55
|
+
|
|
56
|
+
def close(self) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Fecha conexão/canal, se estiverem abertos.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
if self._channel and self._channel.is_open:
|
|
62
|
+
self._channel.close()
|
|
63
|
+
finally:
|
|
64
|
+
if self._connection and self._connection.is_open:
|
|
65
|
+
self._connection.close()
|
|
66
|
+
|
|
67
|
+
# ---------- Consumo ----------
|
|
68
|
+
|
|
69
|
+
def consume(
|
|
70
|
+
self,
|
|
71
|
+
queue_name,
|
|
72
|
+
auto_ack,
|
|
73
|
+
batch_size=1,
|
|
74
|
+
) -> List[Dict[str, Any]]:
|
|
75
|
+
"""
|
|
76
|
+
Gera mensagens da fila como dicionários.
|
|
77
|
+
|
|
78
|
+
Cada item gerado pode conter:
|
|
79
|
+
- payload: corpo da mensagem (json decodado, se possível)
|
|
80
|
+
- routing_key
|
|
81
|
+
- delivery_tag
|
|
82
|
+
- headers
|
|
83
|
+
"""
|
|
84
|
+
assert self._connection is not None
|
|
85
|
+
assert self._channel is not None
|
|
86
|
+
|
|
87
|
+
q = queue_name or self.client.queue_name
|
|
88
|
+
|
|
89
|
+
buffer = deque()
|
|
90
|
+
|
|
91
|
+
# Callback interno que vai encher o buffer
|
|
92
|
+
def _internal_callback(ch, method, properties, body):
|
|
93
|
+
try:
|
|
94
|
+
try:
|
|
95
|
+
payload = json.loads(body.decode("utf-8")) if body else None
|
|
96
|
+
except Exception:
|
|
97
|
+
payload = body
|
|
98
|
+
|
|
99
|
+
if len(buffer) >= batch_size:
|
|
100
|
+
# Já tenho batch, só requeue ou ignora
|
|
101
|
+
if not auto_ack:
|
|
102
|
+
ch.basic_nack(method.delivery_tag, requeue=True)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
buffer.append(
|
|
106
|
+
{
|
|
107
|
+
"data": payload,
|
|
108
|
+
"routing_key": method.routing_key,
|
|
109
|
+
"delivery_tag": method.delivery_tag,
|
|
110
|
+
"headers": getattr(properties, "headers", {}) or {},
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if auto_ack:
|
|
115
|
+
ch.basic_ack(method.delivery_tag)
|
|
116
|
+
|
|
117
|
+
# Cancela consumo após capturar o batch
|
|
118
|
+
if len(buffer) == batch_size:
|
|
119
|
+
self._channel.basic_cancel(consumer_tag)
|
|
120
|
+
except Exception:
|
|
121
|
+
if not auto_ack:
|
|
122
|
+
ch.basic_nack(method.delivery_tag, requeue=True)
|
|
123
|
+
|
|
124
|
+
# Registra o callback
|
|
125
|
+
consumer_tag = self._channel.basic_consume(
|
|
126
|
+
queue=q,
|
|
127
|
+
on_message_callback=_internal_callback,
|
|
128
|
+
auto_ack=auto_ack,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Loop para acumular mensagens
|
|
132
|
+
start = time.time()
|
|
133
|
+
timeout = 2.0
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
while len(buffer) < batch_size:
|
|
137
|
+
self._connection.process_data_events(time_limit=0.1) # type: ignore[union-attr, arg-type]
|
|
138
|
+
# se houver menos mensagem que o tamanho do batch sai do loop por timeout
|
|
139
|
+
if time.time() - start > timeout:
|
|
140
|
+
break
|
|
141
|
+
finally:
|
|
142
|
+
self._channel.basic_cancel(consumer_tag)
|
|
143
|
+
|
|
144
|
+
return list(buffer)
|
|
145
|
+
|
|
146
|
+
def ack_msg(self, delivery_tag) -> None:
|
|
147
|
+
assert self._channel is not None
|
|
148
|
+
try:
|
|
149
|
+
self._channel.basic_ack(delivery_tag=delivery_tag)
|
|
150
|
+
logger.info(f"sucesso ao marcar como lida. tag {delivery_tag}")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error("Falha ao ackar a mensagem")
|
|
153
|
+
logger.error(f"{str(e)}")
|
|
154
|
+
self._channel.basic_nack(delivery_tag, requeue=True)
|
|
155
|
+
|
|
156
|
+
def confirm_message_received(self, delivery_tag: int) -> None:
|
|
157
|
+
if not isinstance(delivery_tag, int):
|
|
158
|
+
try:
|
|
159
|
+
delivery_tag = int(delivery_tag)
|
|
160
|
+
except ValueError:
|
|
161
|
+
raise ValueError("Delivery tag must be an integer.")
|
|
162
|
+
|
|
163
|
+
assert self._connection is not None
|
|
164
|
+
logger.info(f"marcando mensagem como recebida. tag {delivery_tag}")
|
|
165
|
+
ack = functools.partial(self.ack_msg, delivery_tag=delivery_tag)
|
|
166
|
+
self._connection.add_callback_threadsafe(ack)
|
|
167
|
+
|
|
168
|
+
# ---------- Publish ----------
|
|
169
|
+
|
|
170
|
+
def publish(self, message: RabbitmqProducerMessage, exchange: str = "") -> None:
|
|
171
|
+
if not (self._channel and self._channel.is_open):
|
|
172
|
+
logger.warning("Channel closed, attempting reconnect before publish")
|
|
173
|
+
self._connect()
|
|
174
|
+
if not (self._channel and self._channel.is_open):
|
|
175
|
+
raise ConnectionError("Unable to publish: RabbitMQ channel is closed after reconnect attempt")
|
|
176
|
+
|
|
177
|
+
rk = message.payload.get("queue_name") or self.client.queue_name # type: ignore[attr-defined]
|
|
178
|
+
|
|
179
|
+
raw = message.payload.get("body") # type: ignore[attr-defined]
|
|
180
|
+
if not isinstance(raw, (str, bytes)):
|
|
181
|
+
body = json.dumps(raw)
|
|
182
|
+
elif isinstance(raw, str):
|
|
183
|
+
body = raw.encode("utf-8")
|
|
184
|
+
else:
|
|
185
|
+
body = json.dumps(raw, ensure_ascii=False).encode("utf-8")
|
|
186
|
+
|
|
187
|
+
properties = pika.BasicProperties(
|
|
188
|
+
headers=headers_generator(id=message.id, source=message.payload.get("source")), # type: ignore[attr-defined]
|
|
189
|
+
delivery_mode=message.delivery_mode,
|
|
190
|
+
content_type=message.content_type,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
self._channel.basic_publish(
|
|
194
|
+
exchange=exchange,
|
|
195
|
+
routing_key=rk,
|
|
196
|
+
body=body,
|
|
197
|
+
properties=properties,
|
|
198
|
+
)
|
|
199
|
+
logger.info("Message published successfully")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .async_connector import AsyncSQSConnector
|
|
2
|
+
from .async_service import AsyncSQSService
|
|
3
|
+
from .connector import SQSConnector
|
|
4
|
+
from .models import SQSClient, SQSConsumerConfig, SQSProducerConfig
|
|
5
|
+
from .service import SQSService
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AsyncSQSConnector",
|
|
9
|
+
"AsyncSQSService",
|
|
10
|
+
"SQSConnector",
|
|
11
|
+
"SQSService",
|
|
12
|
+
"SQSClient",
|
|
13
|
+
"SQSConsumerConfig",
|
|
14
|
+
"SQSProducerConfig",
|
|
15
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from ..connector import AsyncConnector
|
|
6
|
+
from ..transaction import Transaction
|
|
7
|
+
from .async_service import AsyncSQSService
|
|
8
|
+
from .models import SQSClient, SQSConsumerConfig, SQSProducerConfig
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncSQSConnector(AsyncConnector):
|
|
14
|
+
service: AsyncSQSService
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
client: SQSClient,
|
|
19
|
+
consumer_config: Optional[SQSConsumerConfig] = None,
|
|
20
|
+
producer_config: Optional[SQSProducerConfig] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.service = AsyncSQSService(client)
|
|
23
|
+
self._consumer_config = consumer_config or SQSConsumerConfig()
|
|
24
|
+
self._producer_config = producer_config or SQSProducerConfig()
|
|
25
|
+
|
|
26
|
+
async def fetch_transactions_async(
|
|
27
|
+
self,
|
|
28
|
+
batch_size: int = 1,
|
|
29
|
+
queue_url: Optional[str] = None,
|
|
30
|
+
wait_time_seconds: Optional[int] = None,
|
|
31
|
+
visibility_timeout: Optional[int] = None,
|
|
32
|
+
attribute_names: Optional[List[str]] = None,
|
|
33
|
+
message_attribute_names: Optional[List[str]] = None,
|
|
34
|
+
*args,
|
|
35
|
+
**kwargs,
|
|
36
|
+
) -> List[Transaction]:
|
|
37
|
+
cfg = self._consumer_config
|
|
38
|
+
|
|
39
|
+
resolved_queue_url = queue_url or cfg.queue_url
|
|
40
|
+
resolved_wait_time = wait_time_seconds if wait_time_seconds is not None else cfg.wait_time_seconds
|
|
41
|
+
resolved_visibility = visibility_timeout if visibility_timeout is not None else cfg.visibility_timeout
|
|
42
|
+
resolved_attr_names = attribute_names or cfg.attribute_names
|
|
43
|
+
resolved_msg_attr_names = message_attribute_names or cfg.message_attribute_names
|
|
44
|
+
|
|
45
|
+
messages = await self.service.receive_messages(
|
|
46
|
+
queue_url=resolved_queue_url,
|
|
47
|
+
max_number_of_messages=batch_size,
|
|
48
|
+
wait_time_seconds=resolved_wait_time,
|
|
49
|
+
visibility_timeout=resolved_visibility,
|
|
50
|
+
attribute_names=resolved_attr_names,
|
|
51
|
+
message_attribute_names=resolved_msg_attr_names,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not messages:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
effective_queue_url = resolved_queue_url or self.service.client.queue_url
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
Transaction(
|
|
61
|
+
id=msg["MessageId"],
|
|
62
|
+
payload=msg,
|
|
63
|
+
metadata={
|
|
64
|
+
"receipt_handle": msg["ReceiptHandle"],
|
|
65
|
+
"queue_url": effective_queue_url,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
for msg in messages
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
async def dispatch_transactions_async(
|
|
72
|
+
self,
|
|
73
|
+
transactions: List[Transaction],
|
|
74
|
+
queue_url: Optional[str] = None,
|
|
75
|
+
delay_seconds: Optional[int] = None,
|
|
76
|
+
message_group_id: Optional[str] = None,
|
|
77
|
+
message_deduplication_id: Optional[str] = None,
|
|
78
|
+
message_attributes: Optional[Dict[str, Dict]] = None,
|
|
79
|
+
*args,
|
|
80
|
+
**kwargs,
|
|
81
|
+
) -> Dict[str, Any]:
|
|
82
|
+
cfg = self._producer_config
|
|
83
|
+
|
|
84
|
+
resolved_queue_url = queue_url or cfg.queue_url
|
|
85
|
+
resolved_delay = delay_seconds if delay_seconds is not None else cfg.delay_seconds
|
|
86
|
+
resolved_group_id = message_group_id or cfg.message_group_id
|
|
87
|
+
resolved_dedup_id = message_deduplication_id or cfg.message_deduplication_id
|
|
88
|
+
resolved_msg_attrs = message_attributes or cfg.message_attributes
|
|
89
|
+
|
|
90
|
+
entries: List[Dict[str, Any]] = []
|
|
91
|
+
for i, txn in enumerate(transactions):
|
|
92
|
+
body = txn.payload
|
|
93
|
+
if not isinstance(body, str):
|
|
94
|
+
body = json.dumps(body)
|
|
95
|
+
|
|
96
|
+
entry: Dict[str, Any] = {
|
|
97
|
+
"Id": str(i),
|
|
98
|
+
"MessageBody": body,
|
|
99
|
+
"DelaySeconds": resolved_delay,
|
|
100
|
+
}
|
|
101
|
+
if resolved_msg_attrs:
|
|
102
|
+
entry["MessageAttributes"] = resolved_msg_attrs
|
|
103
|
+
if resolved_group_id:
|
|
104
|
+
entry["MessageGroupId"] = resolved_group_id
|
|
105
|
+
|
|
106
|
+
txn_dedup_id = txn.metadata.get("message_deduplication_id") if txn.metadata else None
|
|
107
|
+
dedup_id = txn_dedup_id or resolved_dedup_id
|
|
108
|
+
if dedup_id:
|
|
109
|
+
entry["MessageDeduplicationId"] = dedup_id
|
|
110
|
+
|
|
111
|
+
entries.append(entry)
|
|
112
|
+
|
|
113
|
+
return await self.service.send_message_batch(entries=entries, queue_url=resolved_queue_url)
|
|
114
|
+
|
|
115
|
+
async def get_transactions_count_async(self, queue_url: Optional[str] = None, *args, **kwargs) -> int:
|
|
116
|
+
attrs = await self.service.get_queue_attributes(
|
|
117
|
+
attribute_names=["ApproximateNumberOfMessages"],
|
|
118
|
+
queue_url=queue_url,
|
|
119
|
+
)
|
|
120
|
+
return int(attrs.get("ApproximateNumberOfMessages", 0))
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from aiobotocore.session import get_session
|
|
6
|
+
|
|
7
|
+
from .models import SQSClient
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncSQSService:
|
|
13
|
+
_sqs: Any
|
|
14
|
+
_sqs_ctx: Any
|
|
15
|
+
|
|
16
|
+
def __init__(self, client: SQSClient) -> None:
|
|
17
|
+
self.client = client
|
|
18
|
+
|
|
19
|
+
self._session = get_session()
|
|
20
|
+
self._client_kwargs: Dict[str, Any] = {"region_name": client.region_name}
|
|
21
|
+
if client.aws_access_key_id:
|
|
22
|
+
self._client_kwargs["aws_access_key_id"] = client.aws_access_key_id
|
|
23
|
+
if client.aws_secret_access_key:
|
|
24
|
+
self._client_kwargs["aws_secret_access_key"] = client.aws_secret_access_key
|
|
25
|
+
if client.aws_session_token:
|
|
26
|
+
self._client_kwargs["aws_session_token"] = client.aws_session_token
|
|
27
|
+
if client.endpoint_url:
|
|
28
|
+
self._client_kwargs["endpoint_url"] = client.endpoint_url
|
|
29
|
+
|
|
30
|
+
self._sqs_ctx = None
|
|
31
|
+
self._sqs = None
|
|
32
|
+
|
|
33
|
+
async def _get_client(self) -> Any:
|
|
34
|
+
if self._sqs is None:
|
|
35
|
+
self._sqs_ctx = self._session.create_client("sqs", **self._client_kwargs)
|
|
36
|
+
self._sqs = await self._sqs_ctx.__aenter__()
|
|
37
|
+
return self._sqs
|
|
38
|
+
|
|
39
|
+
async def close(self) -> None:
|
|
40
|
+
if self._sqs_ctx is not None:
|
|
41
|
+
await self._sqs_ctx.__aexit__(None, None, None)
|
|
42
|
+
self._sqs = None
|
|
43
|
+
self._sqs_ctx = None
|
|
44
|
+
|
|
45
|
+
# ---------- Receive ----------
|
|
46
|
+
|
|
47
|
+
async def receive_messages(
|
|
48
|
+
self,
|
|
49
|
+
queue_url: Optional[str] = None,
|
|
50
|
+
max_number_of_messages: int = 1,
|
|
51
|
+
wait_time_seconds: int = 20,
|
|
52
|
+
visibility_timeout: Optional[int] = None,
|
|
53
|
+
attribute_names: Optional[List[str]] = None,
|
|
54
|
+
message_attribute_names: Optional[List[str]] = None,
|
|
55
|
+
) -> List[Dict[str, Any]]:
|
|
56
|
+
"""
|
|
57
|
+
Receive up to `max_number_of_messages` from the queue.
|
|
58
|
+
|
|
59
|
+
SQS caps MaxNumberOfMessages at 10 per API call, so for larger
|
|
60
|
+
batch sizes this loops until the requested count is reached or
|
|
61
|
+
the queue returns no more messages.
|
|
62
|
+
"""
|
|
63
|
+
url = queue_url or self.client.queue_url
|
|
64
|
+
if not url:
|
|
65
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
66
|
+
|
|
67
|
+
sqs = await self._get_client()
|
|
68
|
+
collected: List[Dict[str, Any]] = []
|
|
69
|
+
remaining = max_number_of_messages
|
|
70
|
+
|
|
71
|
+
first_call = True
|
|
72
|
+
while remaining > 0:
|
|
73
|
+
fetch_count = min(remaining, 10)
|
|
74
|
+
|
|
75
|
+
params: Dict[str, Any] = {
|
|
76
|
+
"QueueUrl": url,
|
|
77
|
+
"MaxNumberOfMessages": fetch_count,
|
|
78
|
+
"WaitTimeSeconds": wait_time_seconds if first_call else 0,
|
|
79
|
+
"AttributeNames": attribute_names or ["All"],
|
|
80
|
+
"MessageAttributeNames": message_attribute_names or ["All"],
|
|
81
|
+
}
|
|
82
|
+
if visibility_timeout is not None:
|
|
83
|
+
params["VisibilityTimeout"] = visibility_timeout
|
|
84
|
+
|
|
85
|
+
response = await sqs.receive_message(**params)
|
|
86
|
+
messages = response.get("Messages", [])
|
|
87
|
+
first_call = False
|
|
88
|
+
|
|
89
|
+
if not messages:
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
collected.extend(messages)
|
|
93
|
+
remaining -= len(messages)
|
|
94
|
+
|
|
95
|
+
if len(messages) < fetch_count:
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
return collected
|
|
99
|
+
|
|
100
|
+
# ---------- Send ----------
|
|
101
|
+
|
|
102
|
+
async def send_message(
|
|
103
|
+
self,
|
|
104
|
+
message_body: Any,
|
|
105
|
+
queue_url: Optional[str] = None,
|
|
106
|
+
delay_seconds: int = 0,
|
|
107
|
+
message_attributes: Optional[Dict[str, Dict]] = None,
|
|
108
|
+
message_group_id: Optional[str] = None,
|
|
109
|
+
message_deduplication_id: Optional[str] = None,
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
url = queue_url or self.client.queue_url
|
|
112
|
+
if not url:
|
|
113
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
114
|
+
|
|
115
|
+
sqs = await self._get_client()
|
|
116
|
+
body = json.dumps(message_body) if not isinstance(message_body, str) else message_body
|
|
117
|
+
|
|
118
|
+
params: Dict[str, Any] = {
|
|
119
|
+
"QueueUrl": url,
|
|
120
|
+
"MessageBody": body,
|
|
121
|
+
"DelaySeconds": delay_seconds,
|
|
122
|
+
}
|
|
123
|
+
if message_attributes:
|
|
124
|
+
params["MessageAttributes"] = message_attributes
|
|
125
|
+
if message_group_id:
|
|
126
|
+
params["MessageGroupId"] = message_group_id
|
|
127
|
+
if message_deduplication_id:
|
|
128
|
+
params["MessageDeduplicationId"] = message_deduplication_id
|
|
129
|
+
|
|
130
|
+
return await sqs.send_message(**params)
|
|
131
|
+
|
|
132
|
+
async def send_message_batch(
|
|
133
|
+
self,
|
|
134
|
+
entries: List[Dict[str, Any]],
|
|
135
|
+
queue_url: Optional[str] = None,
|
|
136
|
+
) -> Dict[str, Any]:
|
|
137
|
+
"""
|
|
138
|
+
Send messages in batches of up to 10 (SQS limit).
|
|
139
|
+
Each entry must have at minimum 'Id' and 'MessageBody'.
|
|
140
|
+
"""
|
|
141
|
+
url = queue_url or self.client.queue_url
|
|
142
|
+
if not url:
|
|
143
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
144
|
+
|
|
145
|
+
sqs = await self._get_client()
|
|
146
|
+
successful = []
|
|
147
|
+
failed = []
|
|
148
|
+
|
|
149
|
+
for i in range(0, len(entries), 10):
|
|
150
|
+
chunk = entries[i : i + 10]
|
|
151
|
+
response = await sqs.send_message_batch(QueueUrl=url, Entries=chunk)
|
|
152
|
+
successful.extend(response.get("Successful", []))
|
|
153
|
+
failed.extend(response.get("Failed", []))
|
|
154
|
+
|
|
155
|
+
return {"Successful": successful, "Failed": failed}
|
|
156
|
+
|
|
157
|
+
# ---------- Delete ----------
|
|
158
|
+
|
|
159
|
+
async def delete_message(self, receipt_handle: str, queue_url: Optional[str] = None) -> None:
|
|
160
|
+
url = queue_url or self.client.queue_url
|
|
161
|
+
if not url:
|
|
162
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
163
|
+
|
|
164
|
+
sqs = await self._get_client()
|
|
165
|
+
await sqs.delete_message(QueueUrl=url, ReceiptHandle=receipt_handle)
|
|
166
|
+
|
|
167
|
+
async def delete_message_batch(
|
|
168
|
+
self,
|
|
169
|
+
entries: List[Dict[str, str]],
|
|
170
|
+
queue_url: Optional[str] = None,
|
|
171
|
+
) -> Dict[str, Any]:
|
|
172
|
+
"""
|
|
173
|
+
Delete messages in batches of up to 10.
|
|
174
|
+
Each entry must have 'Id' and 'ReceiptHandle'.
|
|
175
|
+
"""
|
|
176
|
+
url = queue_url or self.client.queue_url
|
|
177
|
+
if not url:
|
|
178
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
179
|
+
|
|
180
|
+
sqs = await self._get_client()
|
|
181
|
+
successful = []
|
|
182
|
+
failed = []
|
|
183
|
+
|
|
184
|
+
for i in range(0, len(entries), 10):
|
|
185
|
+
chunk = entries[i : i + 10]
|
|
186
|
+
response = await sqs.delete_message_batch(QueueUrl=url, Entries=chunk)
|
|
187
|
+
successful.extend(response.get("Successful", []))
|
|
188
|
+
failed.extend(response.get("Failed", []))
|
|
189
|
+
|
|
190
|
+
return {"Successful": successful, "Failed": failed}
|
|
191
|
+
|
|
192
|
+
# ---------- Queue Management ----------
|
|
193
|
+
|
|
194
|
+
async def list_queues(self, prefix: Optional[str] = None) -> List[str]:
|
|
195
|
+
sqs = await self._get_client()
|
|
196
|
+
params: Dict[str, Any] = {}
|
|
197
|
+
if prefix:
|
|
198
|
+
params["QueueNamePrefix"] = prefix
|
|
199
|
+
|
|
200
|
+
response = await sqs.list_queues(**params)
|
|
201
|
+
return response.get("QueueUrls", [])
|
|
202
|
+
|
|
203
|
+
async def get_queue_attributes(
|
|
204
|
+
self,
|
|
205
|
+
attribute_names: Optional[List[str]] = None,
|
|
206
|
+
queue_url: Optional[str] = None,
|
|
207
|
+
) -> Dict[str, str]:
|
|
208
|
+
url = queue_url or self.client.queue_url
|
|
209
|
+
if not url:
|
|
210
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
211
|
+
|
|
212
|
+
sqs = await self._get_client()
|
|
213
|
+
response = await sqs.get_queue_attributes(
|
|
214
|
+
QueueUrl=url,
|
|
215
|
+
AttributeNames=attribute_names or ["All"],
|
|
216
|
+
)
|
|
217
|
+
return response.get("Attributes", {})
|
|
218
|
+
|
|
219
|
+
async def create_queue(
|
|
220
|
+
self,
|
|
221
|
+
queue_name: str,
|
|
222
|
+
attributes: Optional[Dict[str, str]] = None,
|
|
223
|
+
) -> str:
|
|
224
|
+
sqs = await self._get_client()
|
|
225
|
+
params: Dict[str, Any] = {"QueueName": queue_name}
|
|
226
|
+
if attributes:
|
|
227
|
+
params["Attributes"] = attributes
|
|
228
|
+
|
|
229
|
+
response = await sqs.create_queue(**params)
|
|
230
|
+
return response["QueueUrl"]
|
|
231
|
+
|
|
232
|
+
async def delete_queue(self, queue_url: Optional[str] = None) -> None:
|
|
233
|
+
url = queue_url or self.client.queue_url
|
|
234
|
+
if not url:
|
|
235
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
236
|
+
|
|
237
|
+
sqs = await self._get_client()
|
|
238
|
+
await sqs.delete_queue(QueueUrl=url)
|
|
239
|
+
|
|
240
|
+
async def purge_queue(self, queue_url: Optional[str] = None) -> None:
|
|
241
|
+
url = queue_url or self.client.queue_url
|
|
242
|
+
if not url:
|
|
243
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
244
|
+
|
|
245
|
+
sqs = await self._get_client()
|
|
246
|
+
await sqs.purge_queue(QueueUrl=url)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from ..connector import Connector
|
|
6
|
+
from ..transaction import Transaction
|
|
7
|
+
from .models import SQSClient, SQSConsumerConfig, SQSProducerConfig
|
|
8
|
+
from .service import SQSService
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SQSConnector(Connector):
|
|
14
|
+
service: SQSService
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
client: SQSClient,
|
|
19
|
+
consumer_config: Optional[SQSConsumerConfig] = None,
|
|
20
|
+
producer_config: Optional[SQSProducerConfig] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.service = SQSService(client)
|
|
23
|
+
self._consumer_config = consumer_config or SQSConsumerConfig()
|
|
24
|
+
self._producer_config = producer_config or SQSProducerConfig()
|
|
25
|
+
|
|
26
|
+
def fetch_transactions(
|
|
27
|
+
self,
|
|
28
|
+
batch_size: int = 1,
|
|
29
|
+
queue_url: Optional[str] = None,
|
|
30
|
+
wait_time_seconds: Optional[int] = None,
|
|
31
|
+
visibility_timeout: Optional[int] = None,
|
|
32
|
+
attribute_names: Optional[List[str]] = None,
|
|
33
|
+
message_attribute_names: Optional[List[str]] = None,
|
|
34
|
+
*args,
|
|
35
|
+
**kwargs,
|
|
36
|
+
) -> List[Transaction]:
|
|
37
|
+
cfg = self._consumer_config
|
|
38
|
+
|
|
39
|
+
resolved_queue_url = queue_url or cfg.queue_url
|
|
40
|
+
resolved_wait_time = wait_time_seconds if wait_time_seconds is not None else cfg.wait_time_seconds
|
|
41
|
+
resolved_visibility = visibility_timeout if visibility_timeout is not None else cfg.visibility_timeout
|
|
42
|
+
resolved_attr_names = attribute_names or cfg.attribute_names
|
|
43
|
+
resolved_msg_attr_names = message_attribute_names or cfg.message_attribute_names
|
|
44
|
+
|
|
45
|
+
messages = self.service.receive_messages(
|
|
46
|
+
queue_url=resolved_queue_url,
|
|
47
|
+
max_number_of_messages=batch_size,
|
|
48
|
+
wait_time_seconds=resolved_wait_time,
|
|
49
|
+
visibility_timeout=resolved_visibility,
|
|
50
|
+
attribute_names=resolved_attr_names,
|
|
51
|
+
message_attribute_names=resolved_msg_attr_names,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not messages:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
effective_queue_url = resolved_queue_url or self.service.client.queue_url
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
Transaction(
|
|
61
|
+
id=msg["MessageId"],
|
|
62
|
+
payload=msg,
|
|
63
|
+
metadata={
|
|
64
|
+
"receipt_handle": msg["ReceiptHandle"],
|
|
65
|
+
"queue_url": effective_queue_url,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
for msg in messages
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
def dispatch_transactions(
|
|
72
|
+
self,
|
|
73
|
+
transactions: List[Transaction],
|
|
74
|
+
queue_url: Optional[str] = None,
|
|
75
|
+
delay_seconds: Optional[int] = None,
|
|
76
|
+
message_group_id: Optional[str] = None,
|
|
77
|
+
message_deduplication_id: Optional[str] = None,
|
|
78
|
+
message_attributes: Optional[Dict[str, Dict]] = None,
|
|
79
|
+
*args,
|
|
80
|
+
**kwargs,
|
|
81
|
+
) -> Dict[str, Any]:
|
|
82
|
+
cfg = self._producer_config
|
|
83
|
+
|
|
84
|
+
resolved_queue_url = queue_url or cfg.queue_url
|
|
85
|
+
resolved_delay = delay_seconds if delay_seconds is not None else cfg.delay_seconds
|
|
86
|
+
resolved_group_id = message_group_id or cfg.message_group_id
|
|
87
|
+
resolved_dedup_id = message_deduplication_id or cfg.message_deduplication_id
|
|
88
|
+
resolved_msg_attrs = message_attributes or cfg.message_attributes
|
|
89
|
+
|
|
90
|
+
entries: List[Dict[str, Any]] = []
|
|
91
|
+
for i, txn in enumerate(transactions):
|
|
92
|
+
body = txn.payload
|
|
93
|
+
if not isinstance(body, str):
|
|
94
|
+
body = json.dumps(body)
|
|
95
|
+
|
|
96
|
+
entry: Dict[str, Any] = {
|
|
97
|
+
"Id": str(i),
|
|
98
|
+
"MessageBody": body,
|
|
99
|
+
"DelaySeconds": resolved_delay,
|
|
100
|
+
}
|
|
101
|
+
if resolved_msg_attrs:
|
|
102
|
+
entry["MessageAttributes"] = resolved_msg_attrs
|
|
103
|
+
if resolved_group_id:
|
|
104
|
+
entry["MessageGroupId"] = resolved_group_id
|
|
105
|
+
|
|
106
|
+
txn_dedup_id = txn.metadata.get("message_deduplication_id") if txn.metadata else None
|
|
107
|
+
dedup_id = txn_dedup_id or resolved_dedup_id
|
|
108
|
+
if dedup_id:
|
|
109
|
+
entry["MessageDeduplicationId"] = dedup_id
|
|
110
|
+
|
|
111
|
+
entries.append(entry)
|
|
112
|
+
|
|
113
|
+
return self.service.send_message_batch(entries=entries, queue_url=resolved_queue_url)
|
|
114
|
+
|
|
115
|
+
def get_transactions_count(self, queue_url: Optional[str] = None, *args, **kwargs) -> int:
|
|
116
|
+
attrs = self.service.get_queue_attributes(
|
|
117
|
+
attribute_names=["ApproximateNumberOfMessages"],
|
|
118
|
+
queue_url=queue_url,
|
|
119
|
+
)
|
|
120
|
+
return int(attrs.get("ApproximateNumberOfMessages", 0))
|