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,417 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import ssl as ssl_module
5
+ from typing import Any, Callable, Dict, List, Optional
6
+
7
+ import aio_pika
8
+ import aio_pika.exceptions
9
+ import aiormq.exceptions
10
+ from aio_pika.abc import (
11
+ AbstractChannel,
12
+ AbstractExchange,
13
+ AbstractIncomingMessage,
14
+ AbstractQueue,
15
+ AbstractRobustConnection,
16
+ )
17
+
18
+ from .models import AsyncRabbitmqClient, AsyncRabbitmqConsumerConfig, AsyncRabbitmqProducerConfig
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # Exception classes that signal the underlying channel is no longer usable.
24
+ # We catch these on ack/nack and surface a typed DeadChannelError so the
25
+ # consumer mixin can short-circuit cleanly instead of cascading further
26
+ # failures (nack on the same dead channel).
27
+ _DEAD_CHANNEL_EXCEPTIONS: tuple[type[BaseException], ...] = (
28
+ aio_pika.exceptions.MessageProcessError,
29
+ aio_pika.exceptions.ChannelClosed,
30
+ aio_pika.exceptions.ChannelInvalidStateError,
31
+ aiormq.exceptions.ChannelInvalidStateError,
32
+ )
33
+
34
+
35
+ class AsyncRabbitMQService:
36
+ def __init__(self, client: AsyncRabbitmqClient) -> None:
37
+ self.client = client
38
+
39
+ self._connection: Optional[AbstractRobustConnection] = None
40
+ # Separate channels for consume and publish so a consumer-side outage
41
+ # (e.g. Basic.Cancel from broker after a force-recreate) cannot poison
42
+ # the publish path, and vice-versa.
43
+ self._consume_channel: Optional[AbstractChannel] = None
44
+ self._publish_channel: Optional[AbstractChannel] = None
45
+ # Caches keyed off the consume channel; invalidated whenever the
46
+ # consume channel is closed / cancelled so the next consume() rebuilds
47
+ # the subscription on a fresh channel and the broker redelivers any
48
+ # in-flight prefetch.
49
+ self._exchanges: Dict[str, AbstractExchange] = {}
50
+ self._queues: Dict[str, AbstractQueue] = {}
51
+
52
+ # ---------- Connection / Channel ----------
53
+
54
+ async def _get_connection(self) -> AbstractRobustConnection:
55
+ if self._connection is None or self._connection.is_closed:
56
+ url = self.client.get_url()
57
+
58
+ kwargs: Dict[str, Any] = {}
59
+ if self.client.ssl_enabled:
60
+ ctx = ssl_module.create_default_context()
61
+ if self.client.ssl_ca_certs:
62
+ ctx.load_verify_locations(self.client.ssl_ca_certs)
63
+ kwargs["ssl"] = True
64
+ kwargs["ssl_context"] = ctx
65
+
66
+ self._connection = await aio_pika.connect_robust(
67
+ url,
68
+ heartbeat=self.client.heartbeat,
69
+ **kwargs,
70
+ )
71
+ logger.info("Connected to RabbitMQ at %s", url.split("@")[-1] if "@" in url else url)
72
+
73
+ return self._connection
74
+
75
+ def _invalidate_consume_channel(self, reason: str = "explicit invalidation") -> None:
76
+ """Drop cached consume channel + queue/exchange handles.
77
+
78
+ Called when the consume channel is closed by the broker (e.g. after
79
+ a Basic.Cancel during force-recreate) or when an ack/nack discovers
80
+ the channel is dead. The next ``consume()`` call will then rebuild
81
+ the subscription on a fresh channel, which causes the broker to
82
+ redeliver any messages that were stuck in the previous prefetch
83
+ buffer to the dead consumer tag.
84
+ """
85
+ if self._consume_channel is None and not self._queues and not self._exchanges:
86
+ return
87
+ logger.warning("Invalidating consume channel cache (%s)", reason)
88
+ self._consume_channel = None
89
+ self._queues.clear()
90
+ self._exchanges.clear()
91
+
92
+ def _on_consume_channel_close(self, *args: Any, **kwargs: Any) -> None:
93
+ """Callback registered with ``channel.add_close_callback``.
94
+
95
+ ``aio_pika`` invokes close callbacks with varying signatures across
96
+ versions; accept ``*args, **kwargs`` to stay compatible.
97
+ """
98
+ exc = args[1] if len(args) >= 2 else kwargs.get("exc")
99
+ reason = f"channel closed: {exc!r}" if exc is not None else "channel closed"
100
+ self._invalidate_consume_channel(reason)
101
+
102
+ async def _get_consume_channel(self, prefetch_count: Optional[int] = None) -> AbstractChannel:
103
+ if self._consume_channel is None or self._consume_channel.is_closed:
104
+ connection = await self._get_connection()
105
+ self._consume_channel = await connection.channel()
106
+ # ``add_close_callback`` is not part of the abstract interface
107
+ # but is provided by the concrete ``Channel`` class. Older
108
+ # aio_pika versions and test mocks may not expose it, so we
109
+ # access via getattr and skip silently when absent — the
110
+ # consume() path also self-heals via is_closed checks.
111
+ register_close = getattr(self._consume_channel, "add_close_callback", None)
112
+ if callable(register_close):
113
+ try:
114
+ register_close(self._on_consume_channel_close)
115
+ except TypeError:
116
+ logger.debug("Consume channel close-callback registration rejected; skipping")
117
+ else:
118
+ logger.debug("Consume channel does not support add_close_callback; skipping registration")
119
+ if prefetch_count is not None:
120
+ await self._consume_channel.set_qos(prefetch_count=prefetch_count)
121
+ logger.debug("Consume channel QoS set to prefetch_count=%d", prefetch_count)
122
+
123
+ return self._consume_channel
124
+
125
+ async def _get_publish_channel(self) -> AbstractChannel:
126
+ if self._publish_channel is None or self._publish_channel.is_closed:
127
+ connection = await self._get_connection()
128
+ self._publish_channel = await connection.channel()
129
+
130
+ return self._publish_channel
131
+
132
+ # Backwards-compatible alias preserved for any external callers / tests
133
+ # that referenced the original single-channel accessor. Defaults to the
134
+ # consume channel since that was the original behaviour for consume().
135
+ async def _get_channel(self, prefetch_count: Optional[int] = None) -> AbstractChannel:
136
+ return await self._get_consume_channel(prefetch_count=prefetch_count)
137
+
138
+ # ---------- Declarations ----------
139
+
140
+ async def declare_exchange(
141
+ self,
142
+ name: str,
143
+ exchange_type: str = "topic",
144
+ durable: bool = True,
145
+ ) -> AbstractExchange:
146
+ if name in self._exchanges:
147
+ return self._exchanges[name]
148
+
149
+ channel = await self._get_consume_channel()
150
+ ex_type = aio_pika.ExchangeType(exchange_type)
151
+ exchange = await channel.declare_exchange(name, ex_type, durable=durable)
152
+ self._exchanges[name] = exchange
153
+ logger.debug("Declared exchange: name=%s type=%s durable=%s", name, exchange_type, durable)
154
+ return exchange
155
+
156
+ async def declare_queue(
157
+ self,
158
+ name: str,
159
+ durable: bool = True,
160
+ arguments: Optional[Dict[str, Any]] = None,
161
+ ) -> AbstractQueue:
162
+ # Cache key includes ``arguments`` so two configs that target the same
163
+ # queue name but with different x-arguments (e.g. different DLX wiring)
164
+ # cannot silently share a cached handle. Without this, the second call
165
+ # would get the first declaration's queue object back regardless of its
166
+ # ``arguments`` payload — masking misconfiguration in tests and dev.
167
+ cache_key = self._queue_cache_key(name, arguments)
168
+ if cache_key in self._queues:
169
+ return self._queues[cache_key]
170
+
171
+ channel = await self._get_consume_channel()
172
+ queue = await channel.declare_queue(name, durable=durable, arguments=arguments)
173
+ self._queues[cache_key] = queue
174
+ logger.debug(
175
+ "Declared queue: name=%s durable=%s arguments=%s",
176
+ name,
177
+ durable,
178
+ arguments or {},
179
+ )
180
+ return queue
181
+
182
+ @staticmethod
183
+ def _queue_cache_key(name: str, arguments: Optional[Dict[str, Any]]) -> str:
184
+ if not arguments:
185
+ return name
186
+ # Sort for stable hashing regardless of insertion order; values are
187
+ # rendered via repr to handle non-hashable values (lists, dicts) that
188
+ # AMQP allows in the x-arguments table.
189
+ rendered = ",".join(f"{k}={arguments[k]!r}" for k in sorted(arguments))
190
+ return f"{name}#{rendered}"
191
+
192
+ async def bind_queue(
193
+ self,
194
+ queue: AbstractQueue,
195
+ exchange: AbstractExchange,
196
+ routing_key: str,
197
+ ) -> None:
198
+ await queue.bind(exchange, routing_key=routing_key)
199
+ logger.debug("Bound queue=%s to exchange=%s with key=%s", queue.name, exchange.name, routing_key)
200
+
201
+ # ---------- Consume ----------
202
+
203
+ async def consume(
204
+ self,
205
+ config: AsyncRabbitmqConsumerConfig,
206
+ batch_size: int = 1,
207
+ ) -> List[Dict[str, Any]]:
208
+ """
209
+ Fetch up to batch_size messages from the configured queue.
210
+
211
+ Declares exchange/queue/bindings on first call, then iterates the
212
+ queue collecting messages until batch_size is reached or
213
+ consume_timeout elapses.
214
+
215
+ Returns raw message dicts without acknowledging — the caller
216
+ is responsible for ack/nack via the delivery_tag in metadata.
217
+ """
218
+ # Ensure the consume channel exists with the requested prefetch QoS.
219
+ # The return value is unused here because declare_exchange/declare_queue
220
+ # re-fetch the channel from the cache.
221
+ await self._get_consume_channel(prefetch_count=config.prefetch_count)
222
+
223
+ if config.exchange_name:
224
+ exchange = await self.declare_exchange(
225
+ config.exchange_name,
226
+ config.exchange_type,
227
+ durable=config.durable,
228
+ )
229
+ else:
230
+ exchange = None
231
+
232
+ queue = await self.declare_queue(
233
+ config.queue_name,
234
+ durable=config.durable,
235
+ arguments=config.queue_arguments or None,
236
+ )
237
+
238
+ if exchange is not None:
239
+ for key in config.binding_keys:
240
+ await self.bind_queue(queue, exchange, key)
241
+
242
+ buffer: List[Dict[str, Any]] = []
243
+ timeout = config.consume_timeout
244
+
245
+ try:
246
+ async with asyncio.timeout(timeout): # type: ignore[attr-defined]
247
+ async with queue.iterator(no_ack=config.auto_ack) as iterator:
248
+ self._register_consumer_cancel_callback(iterator)
249
+ async for message in iterator:
250
+ msg_dict = self._message_to_dict(message)
251
+ buffer.append(msg_dict)
252
+ if len(buffer) >= batch_size:
253
+ break
254
+ except TimeoutError:
255
+ pass
256
+ except _DEAD_CHANNEL_EXCEPTIONS as exc:
257
+ # Broker cancelled this subscription mid-iteration; invalidate
258
+ # the cached channel so the next call rebuilds against a fresh
259
+ # consumer and the broker redelivers what we had prefetched.
260
+ self._invalidate_consume_channel(f"consume aborted: {exc!r}")
261
+ return buffer
262
+ finally:
263
+ # If the channel was closed during iteration, make sure we don't
264
+ # hand back a stale cache to the next caller.
265
+ if self._consume_channel is not None and self._consume_channel.is_closed:
266
+ self._invalidate_consume_channel("consume channel observed closed after iteration")
267
+
268
+ return buffer
269
+
270
+ def _register_consumer_cancel_callback(self, iterator: Any) -> None:
271
+ """Best-effort registration of an on-cancel callback on the iterator's consumer.
272
+
273
+ ``aio_pika`` does not expose a stable public API for this across
274
+ versions; we probe for known attributes and fall back silently.
275
+ The channel close callback in :meth:`_get_consume_channel` provides
276
+ the primary defence — this is belt-and-braces.
277
+ """
278
+ candidate: Optional[Callable[[Callable[..., Any]], Any]] = None
279
+ for attr in ("add_on_cancel_callback", "add_close_callback"):
280
+ consumer_obj = getattr(iterator, "_consumer", None) or getattr(iterator, "consumer", None)
281
+ if consumer_obj is not None:
282
+ candidate = getattr(consumer_obj, attr, None)
283
+ if candidate is not None:
284
+ break
285
+ candidate = getattr(iterator, attr, None)
286
+ if candidate is not None:
287
+ break
288
+
289
+ if candidate is None:
290
+ return
291
+
292
+ def _on_cancel(*_args: Any, **_kwargs: Any) -> None:
293
+ self._invalidate_consume_channel("consumer cancelled by broker (Basic.Cancel)")
294
+
295
+ try:
296
+ candidate(_on_cancel)
297
+ except (TypeError, AttributeError):
298
+ logger.debug("Iterator consumer does not accept cancel callback; skipping")
299
+
300
+ @staticmethod
301
+ def _message_to_dict(message: AbstractIncomingMessage) -> Dict[str, Any]:
302
+ try:
303
+ body = json.loads(message.body.decode("utf-8"))
304
+ except Exception:
305
+ body = message.body
306
+
307
+ return {
308
+ "body": body,
309
+ "routing_key": message.routing_key or "",
310
+ "delivery_tag": message.delivery_tag,
311
+ "headers": dict(message.headers) if message.headers else {},
312
+ "content_type": message.content_type,
313
+ "message_id": message.message_id,
314
+ "correlation_id": message.correlation_id,
315
+ "_message": message,
316
+ }
317
+
318
+ # ---------- Publish ----------
319
+
320
+ async def publish(
321
+ self,
322
+ config: AsyncRabbitmqProducerConfig,
323
+ body: bytes,
324
+ routing_key: Optional[str] = None,
325
+ headers: Optional[Dict[str, Any]] = None,
326
+ ) -> None:
327
+ channel = await self._get_publish_channel()
328
+
329
+ if config.exchange_name:
330
+ exchange = await self._declare_publish_exchange(
331
+ channel,
332
+ config.exchange_name,
333
+ config.exchange_type,
334
+ durable=config.durable,
335
+ )
336
+ else:
337
+ exchange = channel.default_exchange
338
+
339
+ rk = routing_key or config.routing_key
340
+
341
+ delivery_mode = (
342
+ aio_pika.DeliveryMode.PERSISTENT if config.delivery_mode == 2 else aio_pika.DeliveryMode.NOT_PERSISTENT
343
+ )
344
+
345
+ message = aio_pika.Message(
346
+ body=body,
347
+ content_type=config.content_type,
348
+ delivery_mode=delivery_mode,
349
+ headers=headers,
350
+ )
351
+
352
+ await exchange.publish(message, routing_key=rk)
353
+ logger.debug("Published message to exchange=%s routing_key=%s", config.exchange_name or "(default)", rk)
354
+
355
+ async def _declare_publish_exchange(
356
+ self,
357
+ channel: AbstractChannel,
358
+ name: str,
359
+ exchange_type: str,
360
+ durable: bool,
361
+ ) -> AbstractExchange:
362
+ # Publish exchanges live on the publish channel and are not cached
363
+ # alongside consume-side declarations; declare-on-demand is cheap
364
+ # because RabbitMQ treats matching declarations as idempotent.
365
+ ex_type = aio_pika.ExchangeType(exchange_type)
366
+ return await channel.declare_exchange(name, ex_type, durable=durable)
367
+
368
+ # ---------- Ack / Nack ----------
369
+
370
+ async def ack(self, message: AbstractIncomingMessage) -> None:
371
+ # Imported lazily to avoid a circular import between
372
+ # ergon.connector and ergon.task at package init time.
373
+ from ...task import exceptions as task_exceptions
374
+
375
+ try:
376
+ await message.ack()
377
+ except _DEAD_CHANNEL_EXCEPTIONS as exc:
378
+ self._invalidate_consume_channel(f"ack failed: {exc!r}")
379
+ raise task_exceptions.AckOnDeadChannelError(
380
+ delivery_tag=getattr(message, "delivery_tag", None),
381
+ queue=getattr(message, "routing_key", None),
382
+ cause=exc,
383
+ ) from exc
384
+
385
+ async def nack(self, message: AbstractIncomingMessage, requeue: bool = True) -> None:
386
+ from ...task import exceptions as task_exceptions
387
+
388
+ try:
389
+ await message.nack(requeue=requeue)
390
+ except _DEAD_CHANNEL_EXCEPTIONS as exc:
391
+ self._invalidate_consume_channel(f"nack failed: {exc!r}")
392
+ raise task_exceptions.NackOnDeadChannelError(
393
+ delivery_tag=getattr(message, "delivery_tag", None),
394
+ queue=getattr(message, "routing_key", None),
395
+ cause=exc,
396
+ ) from exc
397
+
398
+ # ---------- Lifecycle ----------
399
+
400
+ async def close(self) -> None:
401
+ self._exchanges.clear()
402
+ self._queues.clear()
403
+
404
+ for attr_name in ("_consume_channel", "_publish_channel"):
405
+ channel = getattr(self, attr_name)
406
+ if channel is not None and not channel.is_closed:
407
+ try:
408
+ await channel.close()
409
+ except Exception as exc:
410
+ logger.warning("Error closing %s: %r", attr_name, exc)
411
+ setattr(self, attr_name, None)
412
+
413
+ if self._connection is not None and not self._connection.is_closed:
414
+ await self._connection.close()
415
+ self._connection = None
416
+
417
+ logger.info("RabbitMQ connection closed")
@@ -0,0 +1,54 @@
1
+ from typing import Dict, Generator, List, Optional
2
+
3
+ from ..connector import Connector
4
+ from ..transaction import Transaction
5
+ from .models import RabbitmqClient
6
+ from .service import RabbitMQService
7
+
8
+
9
+ class RabbitMQConnector(Connector):
10
+ service: RabbitMQService
11
+
12
+ def __init__(
13
+ self,
14
+ client: RabbitmqClient,
15
+ default_queue: Optional[str] = None,
16
+ auto_ack: bool = False,
17
+ ) -> None:
18
+ self.service = RabbitMQService(client)
19
+
20
+ self.default_queue = default_queue or client.queue_name
21
+ self.auto_ack = auto_ack
22
+
23
+ # Mantém generators por (queue_name, auto_ack)
24
+ self._iterators: Dict[tuple, Generator[dict, None, None]] = {}
25
+
26
+ def fetch_transactions(
27
+ self,
28
+ batch_size: int = 1,
29
+ queue_name: Optional[str] = None,
30
+ auto_ack: bool = False,
31
+ metadata: Optional[dict] = None,
32
+ *args,
33
+ **kwargs,
34
+ ) -> List[Transaction]:
35
+ """
36
+ Busca até `batch_size` mensagens da fila indicada, transformando
37
+ cada uma em um Transaction do ergon.
38
+ """
39
+ queue_items = self.service.consume(queue_name=queue_name, auto_ack=auto_ack, batch_size=batch_size)
40
+
41
+ if not queue_items:
42
+ return []
43
+
44
+ return [Transaction(id=str(queue_item["delivery_tag"]), payload=queue_item) for queue_item in queue_items]
45
+
46
+ def dispatch_transactions(
47
+ self,
48
+ transactions: List[Transaction],
49
+ *args,
50
+ **kwargs,
51
+ ) -> None:
52
+ """Publish transactions as messages to RabbitMQ."""
53
+ for txn in transactions:
54
+ self.service.publish(txn.payload)
@@ -0,0 +1,14 @@
1
+ import time
2
+ import uuid
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ def headers_generator(
7
+ id: uuid.UUID, content_type: Optional[str] = None, source: Optional[str] = None
8
+ ) -> Dict[str, Any]:
9
+ return {
10
+ "content-type": content_type or "application/json",
11
+ "correlation_id": str(id),
12
+ "source": source,
13
+ "timestamp": int(time.time()),
14
+ }
@@ -0,0 +1,92 @@
1
+ import time
2
+ from typing import Any, Literal, Optional
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class RabbitmqClient(BaseModel):
8
+ host: str = Field(default="localhost", description="The RabbitMQ host")
9
+ port: int = Field(default=5672, description="The RabbitMQ port")
10
+ username: str = Field(description="The RabbitMQ username")
11
+ password: str = Field(description="The RabbitMQ password")
12
+ queue_name: str = Field(default="minha fila", description="The RabbitMQ queue name")
13
+ prefetch_count: int = Field(default=10, description="The number of prefetched messages")
14
+ virtual_host: Optional[str] = Field(default="/", description="The RabbitMQ vhost")
15
+ connection_attempts: int = Field(default=3, description="The RabbitMQ connection retry attempts")
16
+ socket_timeout: float = Field(default=999, description="Socket timeout in seconds")
17
+ heartbeat: Optional[float] = Field(default=600, description="Heartbeat interval in seconds")
18
+ blocked_connection_timeout: Optional[float] = Field(
19
+ default=None, description="Timeout when connection is blocked by the broker"
20
+ )
21
+ ssl_enabled: bool = Field(default=False, description="Enable SSL/TLS")
22
+ ssl_ca_certs: Optional[str] = Field(default=None, description="Path to CA certificate when using SSL")
23
+
24
+
25
+ class RabbitmqProducerMessage(BaseModel):
26
+ queue_name: str = Field(description="The RabbitMQ queue name")
27
+ durable: bool = Field(default=True, description="True for queue persists if broker restart")
28
+ body: dict = Field(default={"message": "Hello World!"}, description="The body of the message")
29
+ delivery_mode: Literal[1, 2] = Field(
30
+ default=2, description="1 for not persistent and 2 for persistent (It needs durable True)"
31
+ )
32
+ content_type: str = Field(default="application/json", description="Content type of the message")
33
+ priority: int = Field(default=0, description="The priority of the message 0 for default")
34
+ timestamp: int = Field(default_factory=lambda: int(time.time()), description="The timestamp of the message")
35
+
36
+
37
+ class RabbitmqConsumerMessage(BaseModel):
38
+ queue_name: str = Field(description="The RabbitMQ queue name")
39
+ auto_ack: bool = Field(default=True, description="True for automatic acknowledgement")
40
+
41
+
42
+ # ---------------------------------------------------------
43
+ # ASYNC MODELS (aio-pika)
44
+ # ---------------------------------------------------------
45
+
46
+
47
+ class AsyncRabbitmqClient(BaseModel):
48
+ url: Optional[str] = Field(default=None, description="Full AMQP URL (takes precedence over individual params)")
49
+ host: str = Field(default="localhost", description="RabbitMQ host")
50
+ port: int = Field(default=5672, description="RabbitMQ port")
51
+ username: str = Field(default="guest", description="RabbitMQ username")
52
+ password: str = Field(default="guest", description="RabbitMQ password")
53
+ virtual_host: str = Field(default="/", description="RabbitMQ virtual host")
54
+ heartbeat: int = Field(default=600, description="Heartbeat interval in seconds")
55
+ connection_attempts: int = Field(default=3, description="Connection retry attempts")
56
+ ssl_enabled: bool = Field(default=False, description="Enable SSL/TLS")
57
+ ssl_ca_certs: Optional[str] = Field(default=None, description="Path to CA certificate when using SSL")
58
+
59
+ def get_url(self) -> str:
60
+ if self.url:
61
+ return self.url
62
+ return f"amqp://{self.username}:{self.password}@{self.host}:{self.port}/{self.virtual_host}"
63
+
64
+
65
+ class AsyncRabbitmqConsumerConfig(BaseModel):
66
+ queue_name: str = Field(description="Queue to consume from")
67
+ exchange_name: str = Field(default="", description="Exchange name (empty for default exchange)")
68
+ exchange_type: str = Field(default="topic", description="Exchange type: topic, direct, fanout, headers")
69
+ binding_keys: list[str] = Field(default=["#"], description="Routing key patterns for queue binding")
70
+ prefetch_count: int = Field(default=10, description="Number of unacknowledged messages allowed")
71
+ durable: bool = Field(default=True, description="Durable exchange and queue declarations")
72
+ auto_ack: bool = Field(default=False, description="Automatically acknowledge messages on delivery")
73
+ consume_timeout: float = Field(default=2.0, description="Max seconds to wait per fetch call")
74
+ queue_arguments: dict[str, Any] = Field(
75
+ default_factory=dict,
76
+ description=(
77
+ "Extra AMQP arguments forwarded to queue.declare (the AMQP `x-arguments` table). "
78
+ "Use for dead-lettering (`x-dead-letter-exchange`, `x-dead-letter-routing-key`), "
79
+ "TTL (`x-message-ttl`), max length, etc. Note: changing these on an existing queue "
80
+ "will cause RabbitMQ to reject re-declaration with PRECONDITION_FAILED; the queue "
81
+ "must be recreated or the args set via a broker-side Policy."
82
+ ),
83
+ )
84
+
85
+
86
+ class AsyncRabbitmqProducerConfig(BaseModel):
87
+ exchange_name: str = Field(default="", description="Exchange to publish to (empty for default exchange)")
88
+ exchange_type: str = Field(default="topic", description="Exchange type: topic, direct, fanout, headers")
89
+ routing_key: str = Field(default="", description="Default routing key for published messages")
90
+ durable: bool = Field(default=True, description="Durable exchange declaration")
91
+ delivery_mode: Literal[1, 2] = Field(default=2, description="1 for transient, 2 for persistent")
92
+ content_type: str = Field(default="application/json", description="Content type of published messages")