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.
Files changed (82) hide show
  1. ergon/__init__.py +13 -0
  2. ergon/bootstrap/src/__project__/__init__.py +0 -0
  3. ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
  4. ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
  5. ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
  6. ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
  7. ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
  8. ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
  9. ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  10. ergon/bootstrap/src/__project__/main.py +9 -0
  11. ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  12. ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
  13. ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  14. ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
  15. ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
  16. ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
  17. ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
  18. ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
  19. ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  20. ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  21. ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  22. ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
  23. ergon/cli.py +174 -0
  24. ergon/connector/__init__.py +64 -0
  25. ergon/connector/connector.py +97 -0
  26. ergon/connector/excel/__init__.py +18 -0
  27. ergon/connector/excel/connector.py +175 -0
  28. ergon/connector/excel/models.py +24 -0
  29. ergon/connector/excel/service.py +98 -0
  30. ergon/connector/pipefy/__init__.py +21 -0
  31. ergon/connector/pipefy/async_connector.py +48 -0
  32. ergon/connector/pipefy/async_service.py +907 -0
  33. ergon/connector/pipefy/connector.py +36 -0
  34. ergon/connector/pipefy/models.py +48 -0
  35. ergon/connector/pipefy/service.py +1016 -0
  36. ergon/connector/pipefy/version.py +1 -0
  37. ergon/connector/postgres/__init__.py +11 -0
  38. ergon/connector/postgres/async_connector.py +119 -0
  39. ergon/connector/postgres/async_service.py +116 -0
  40. ergon/connector/postgres/models.py +34 -0
  41. ergon/connector/rabbitmq/__init__.py +25 -0
  42. ergon/connector/rabbitmq/async_connector.py +120 -0
  43. ergon/connector/rabbitmq/async_service.py +417 -0
  44. ergon/connector/rabbitmq/connector.py +54 -0
  45. ergon/connector/rabbitmq/helper.py +14 -0
  46. ergon/connector/rabbitmq/models.py +92 -0
  47. ergon/connector/rabbitmq/service.py +199 -0
  48. ergon/connector/sqs/__init__.py +15 -0
  49. ergon/connector/sqs/async_connector.py +120 -0
  50. ergon/connector/sqs/async_service.py +246 -0
  51. ergon/connector/sqs/connector.py +120 -0
  52. ergon/connector/sqs/models.py +36 -0
  53. ergon/connector/sqs/service.py +219 -0
  54. ergon/connector/transaction.py +14 -0
  55. ergon/py.typed +0 -0
  56. ergon/service/__init__.py +5 -0
  57. ergon/service/service.py +17 -0
  58. ergon/task/__init__.py +13 -0
  59. ergon/task/base.py +222 -0
  60. ergon/task/exceptions.py +217 -0
  61. ergon/task/helpers.py +691 -0
  62. ergon/task/manager.py +85 -0
  63. ergon/task/mixins/__init__.py +13 -0
  64. ergon/task/mixins/consumer.py +858 -0
  65. ergon/task/mixins/metrics.py +457 -0
  66. ergon/task/mixins/producer.py +486 -0
  67. ergon/task/policies.py +229 -0
  68. ergon/task/runner.py +386 -0
  69. ergon/task/utils.py +64 -0
  70. ergon/telemetry/__init__.py +7 -0
  71. ergon/telemetry/_resource.py +13 -0
  72. ergon/telemetry/logging.py +370 -0
  73. ergon/telemetry/metrics.py +101 -0
  74. ergon/telemetry/tracing.py +152 -0
  75. ergon/utils/__init__.py +5 -0
  76. ergon/utils/env.py +26 -0
  77. ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
  78. ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
  79. ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
  80. ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
  82. 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))