hexcore 2.1.0__tar.gz → 2.2.0__tar.gz
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.
- {hexcore-2.1.0/hexcore.egg-info → hexcore-2.2.0}/PKG-INFO +3 -1
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/api/utils.py +4 -2
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cqrs/middlewares.py +3 -2
- hexcore-2.2.0/hexcore/infrastructure/cqrs/rabbitmq.py +182 -0
- hexcore-2.2.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +66 -0
- hexcore-2.2.0/hexcore/infrastructure/workers/__init__.py +1 -0
- hexcore-2.2.0/hexcore/infrastructure/workers/rabbitmq_worker.py +93 -0
- {hexcore-2.1.0 → hexcore-2.2.0/hexcore.egg-info}/PKG-INFO +3 -1
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/SOURCES.txt +4 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/requires.txt +3 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/pyproject.toml +2 -1
- hexcore-2.2.0/tests/test_rabbitmq_bus.py +95 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_uow_session_regression.py +2 -2
- hexcore-2.1.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -35
- {hexcore-2.1.0 → hexcore-2.2.0}/LICENSE +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/MANIFEST.in +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/README.md +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/__main__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/adapters.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/config.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/factory.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/pipeline.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/registry.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/dtos/base.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/dtos/query.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/config.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/base.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/buses.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/commands.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/exceptions.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/handlers.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/middleware.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/queries.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/serializer.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/events.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/exceptions.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/repositories.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/services.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/uow.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/api/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cache/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cli.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/base.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/utils.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/uow/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/py.typed +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/types.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/top_level.txt +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/scripts/__init__.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/scripts/main.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/setup.cfg +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/conftest.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_basic.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_beanie_query_utils.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_config_loading.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_cqrs.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_domain_service_query.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_infrastructure_query_path.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_query_field_validation.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_repositories_utils.py +0 -0
- {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_use_cases_query.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexcore
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Núcleo reutilizable para proyectos Python con arquitectura hexagonal y event handling. Provee abstracciones, utilidades y contratos para DDD, eventos y desacoplamiento de infraestructura.
|
|
5
5
|
Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,6 +20,8 @@ Requires-Dist: typer>=0.17.3
|
|
|
20
20
|
Requires-Dist: ruff>=0.12.11
|
|
21
21
|
Provides-Extra: procrastinate
|
|
22
22
|
Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
|
|
23
|
+
Provides-Extra: rabbitmq
|
|
24
|
+
Requires-Dist: aio-pika>=9.4.0; extra == "rabbitmq"
|
|
23
25
|
Dynamic: license-file
|
|
24
26
|
|
|
25
27
|
# HexCore [](https://pepy.tech/projects/hexcore)
|
|
@@ -15,13 +15,15 @@ from hexcore.application.dtos.query import (
|
|
|
15
15
|
from hexcore.application.use_cases.query import QueryEntitiesUseCase
|
|
16
16
|
from hexcore.domain.uow import IUnitOfWork
|
|
17
17
|
from hexcore.infrastructure.repositories.orms.sqlalchemy.session import (
|
|
18
|
-
|
|
18
|
+
get_async_db_session,
|
|
19
|
+
get_session_factory,
|
|
19
20
|
)
|
|
20
21
|
from hexcore.infrastructure.uow import SqlAlchemyUnitOfWork, NoSqlUnitOfWork
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
24
|
-
|
|
25
|
+
factory = get_session_factory()
|
|
26
|
+
async with factory() as session:
|
|
25
27
|
yield session
|
|
26
28
|
|
|
27
29
|
|
|
@@ -123,8 +123,9 @@ class TransactionMiddleware(IMiddleware):
|
|
|
123
123
|
# Heurística simple: Si hay una URL de SQL (por defecto siempre la hay), asume SQL.
|
|
124
124
|
# En una app real de Hexcore se puede inyectar esto mejor, pero provee un default funcional.
|
|
125
125
|
if config.sql_database_url:
|
|
126
|
-
from hexcore.infrastructure.repositories.orms.sqlalchemy.session import
|
|
127
|
-
|
|
126
|
+
from hexcore.infrastructure.repositories.orms.sqlalchemy.session import get_session_factory
|
|
127
|
+
factory = get_session_factory()
|
|
128
|
+
return SqlAlchemyUnitOfWork(session=factory())
|
|
128
129
|
return NoSqlUnitOfWork()
|
|
129
130
|
|
|
130
131
|
async def handle(self, message: t.Any, next_handler: NextHandler) -> t.Any:
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adaptador de RabbitMQ para el EventBus de HexCore CQRS usando aio-pika.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import typing as t
|
|
10
|
+
|
|
11
|
+
from hexcore.domain.cqrs.buses import AbstractEventBus
|
|
12
|
+
from hexcore.domain.cqrs.middleware import NextHandler
|
|
13
|
+
from hexcore.application.cqrs.pipeline import MiddlewarePipeline
|
|
14
|
+
from hexcore.domain.cqrs.serializer import AbstractSerializer
|
|
15
|
+
from hexcore.domain.events import DomainEvent
|
|
16
|
+
|
|
17
|
+
if t.TYPE_CHECKING:
|
|
18
|
+
import aio_pika
|
|
19
|
+
from aio_pika.abc import AbstractRobustConnection, AbstractChannel, AbstractExchange
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("hexcore.cqrs.rabbitmq")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RabbitMQEventBus(AbstractEventBus):
|
|
26
|
+
"""
|
|
27
|
+
Implementación del EventBus sobre RabbitMQ (usando aio-pika).
|
|
28
|
+
Permite publicar y consumir eventos de dominio distribuidos de forma asíncrona.
|
|
29
|
+
|
|
30
|
+
Cada instancia de RabbitMQEventBus típicamente usará su propia cola
|
|
31
|
+
enlazada a un exchange central de tipo 'topic' (o 'direct').
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
connection: "AbstractRobustConnection",
|
|
37
|
+
serializer: AbstractSerializer,
|
|
38
|
+
pipeline: MiddlewarePipeline | None = None,
|
|
39
|
+
exchange_name: str = "hexcore.events",
|
|
40
|
+
queue_name: str = "hexcore.worker.queue",
|
|
41
|
+
) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Args:
|
|
44
|
+
connection: Conexión robusta de aio-pika. Debe ser inyectada y gestionada
|
|
45
|
+
por el ciclo de vida de la aplicación.
|
|
46
|
+
serializer: Implementación de AbstractSerializer (ej: PydanticSerializer).
|
|
47
|
+
pipeline: Pipeline opcional de middlewares.
|
|
48
|
+
exchange_name: Nombre del Topic Exchange para distribuir eventos.
|
|
49
|
+
queue_name: Nombre de la cola (queue) exclusiva para este worker/servicio.
|
|
50
|
+
"""
|
|
51
|
+
self._connection = connection
|
|
52
|
+
self._serializer = serializer
|
|
53
|
+
self._pipeline = pipeline or MiddlewarePipeline([])
|
|
54
|
+
|
|
55
|
+
self._exchange_name = exchange_name
|
|
56
|
+
self._queue_name = queue_name
|
|
57
|
+
|
|
58
|
+
self._channel: "AbstractChannel" | None = None
|
|
59
|
+
self._exchange: "AbstractExchange" | None = None
|
|
60
|
+
|
|
61
|
+
# event_name -> list of handlers
|
|
62
|
+
self._handlers: dict[str, list[t.Callable[[DomainEvent], t.Awaitable[None]]]] = {}
|
|
63
|
+
# event_name -> type
|
|
64
|
+
self._event_types: dict[str, type[DomainEvent]] = {}
|
|
65
|
+
|
|
66
|
+
async def _setup(self) -> None:
|
|
67
|
+
"""Configura el channel y exchange una sola vez (lazy)."""
|
|
68
|
+
if self._channel is None or self._exchange is None:
|
|
69
|
+
import aio_pika
|
|
70
|
+
|
|
71
|
+
self._channel = await self._connection.channel()
|
|
72
|
+
# Un topic exchange permite suscripciones flexibles mediante routing keys
|
|
73
|
+
self._exchange = await self._channel.declare_exchange(
|
|
74
|
+
self._exchange_name, aio_pika.ExchangeType.TOPIC, durable=True
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def publish(self, event: DomainEvent) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Publica el evento de dominio a RabbitMQ.
|
|
80
|
+
El routing_key es el nombre del evento.
|
|
81
|
+
"""
|
|
82
|
+
import aio_pika
|
|
83
|
+
|
|
84
|
+
await self._setup()
|
|
85
|
+
assert self._exchange is not None
|
|
86
|
+
|
|
87
|
+
# Serializamos usando el pipeline y serializer configurado
|
|
88
|
+
data = self._serializer.serialize(event)
|
|
89
|
+
body = json.dumps(data).encode("utf-8")
|
|
90
|
+
|
|
91
|
+
message = aio_pika.Message(
|
|
92
|
+
body=body,
|
|
93
|
+
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
|
94
|
+
content_type="application/json",
|
|
95
|
+
message_id=str(event.event_id),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
routing_key = event.event_name
|
|
99
|
+
|
|
100
|
+
await self._exchange.publish(message, routing_key=routing_key)
|
|
101
|
+
logger.debug(f"[RabbitMQEventBus] Publicado evento {routing_key}")
|
|
102
|
+
|
|
103
|
+
def subscribe(
|
|
104
|
+
self,
|
|
105
|
+
event_type: type[DomainEvent],
|
|
106
|
+
handler: t.Callable[[DomainEvent], t.Awaitable[None]],
|
|
107
|
+
) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Registra localmente un handler para un tipo de evento.
|
|
110
|
+
Nota: Esto solo guarda en memoria. Para empezar a consumir RabbitMQ,
|
|
111
|
+
debes llamar a `start_consuming()`.
|
|
112
|
+
"""
|
|
113
|
+
# Obtenemos un event_name estandarizado (usamos una instancia temporal para leer properties o usar una heuristica estática)
|
|
114
|
+
# Por seguridad creamos un string fijo a partir del nombre de clase (como hace DomainEvent.event_name)
|
|
115
|
+
event_name = event_type.__name__.replace("Event", "").upper()
|
|
116
|
+
|
|
117
|
+
if event_name not in self._handlers:
|
|
118
|
+
self._handlers[event_name] = []
|
|
119
|
+
self._event_types[event_name] = event_type
|
|
120
|
+
|
|
121
|
+
self._handlers[event_name].append(handler)
|
|
122
|
+
logger.debug(f"[RabbitMQEventBus] Suscrito localmente a {event_name}")
|
|
123
|
+
|
|
124
|
+
async def _handle_message(self, message: "aio_pika.abc.AbstractIncomingMessage") -> None:
|
|
125
|
+
"""Callback invocado por aio-pika para cada mensaje entrante."""
|
|
126
|
+
async with message.process(requeue=True, ignore_processed=True):
|
|
127
|
+
routing_key = message.routing_key
|
|
128
|
+
if not routing_key:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Verificamos si tenemos handlers para este evento
|
|
132
|
+
if routing_key not in self._handlers:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
# 1. Deserialización
|
|
137
|
+
payload = json.loads(message.body.decode("utf-8"))
|
|
138
|
+
# Nos aseguramos que lleve el __type__ si el serializer lo requiere,
|
|
139
|
+
# o el serializer sabrá reconstruirlo. Idealmente el serializer CQRS asume dict genérico
|
|
140
|
+
event = self._serializer.deserialize(payload)
|
|
141
|
+
|
|
142
|
+
# Ejecutamos todos los handlers concurrentemente o secuencialmente
|
|
143
|
+
# En un bus robusto, el fallo de un handler no debería ocultar a otros, pero si falla,
|
|
144
|
+
# el mensaje no se hace acknowledge. Para simplicidad, lanzamos gather:
|
|
145
|
+
handlers = self._handlers[routing_key]
|
|
146
|
+
|
|
147
|
+
async def _execute_with_pipeline(h: t.Callable[[DomainEvent], t.Awaitable[None]], evt: DomainEvent) -> None:
|
|
148
|
+
async def base_handler(m: DomainEvent) -> t.Any:
|
|
149
|
+
await h(m)
|
|
150
|
+
|
|
151
|
+
# Usamos el pipeline del bus
|
|
152
|
+
await self._pipeline.execute(evt, base_handler)
|
|
153
|
+
|
|
154
|
+
await asyncio.gather(*(
|
|
155
|
+
_execute_with_pipeline(handler, event) for handler in handlers
|
|
156
|
+
))
|
|
157
|
+
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
logger.exception(f"[RabbitMQEventBus] Error procesando mensaje {routing_key}: {exc}")
|
|
160
|
+
# El mensaje será NACKed y requeueado o mandado a Dead Letter (depende del config de aio-pika process)
|
|
161
|
+
raise
|
|
162
|
+
|
|
163
|
+
async def start_consuming(self) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Conecta a la cola, hace bind a todos los eventos suscritos y comienza
|
|
166
|
+
a consumir permanentemente.
|
|
167
|
+
"""
|
|
168
|
+
await self._setup()
|
|
169
|
+
assert self._channel is not None
|
|
170
|
+
assert self._exchange is not None
|
|
171
|
+
|
|
172
|
+
# 1. Declarar la cola para este worker/microservicio
|
|
173
|
+
queue = await self._channel.declare_queue(self._queue_name, durable=True)
|
|
174
|
+
|
|
175
|
+
# 2. Hacer bind para cada evento suscrito
|
|
176
|
+
for event_name in self._handlers.keys():
|
|
177
|
+
await queue.bind(self._exchange, routing_key=event_name)
|
|
178
|
+
logger.info(f"[RabbitMQEventBus] Bind queue '{self._queue_name}' to '{event_name}'")
|
|
179
|
+
|
|
180
|
+
# 3. Empezar a consumir
|
|
181
|
+
await queue.consume(self._handle_message)
|
|
182
|
+
logger.info(f"[RabbitMQEventBus] Escuchando eventos en cola '{self._queue_name}'...")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.ext.asyncio import (
|
|
4
|
+
AsyncEngine,
|
|
5
|
+
AsyncSession,
|
|
6
|
+
async_sessionmaker,
|
|
7
|
+
create_async_engine,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from hexcore.config import LazyConfig
|
|
11
|
+
|
|
12
|
+
_engine: AsyncEngine | None = None
|
|
13
|
+
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_engine() -> AsyncEngine:
|
|
17
|
+
"""
|
|
18
|
+
Retorna el engine asíncrono de SQLAlchemy.
|
|
19
|
+
Se inicializa de forma lazy (solo cuando se solicita).
|
|
20
|
+
"""
|
|
21
|
+
global _engine
|
|
22
|
+
if _engine is None:
|
|
23
|
+
_engine = create_async_engine(
|
|
24
|
+
LazyConfig.get_config().async_sql_database_url,
|
|
25
|
+
# echo=True,
|
|
26
|
+
)
|
|
27
|
+
return _engine
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
|
31
|
+
"""
|
|
32
|
+
Retorna la factoría de sesiones de SQLAlchemy.
|
|
33
|
+
Se inicializa de forma lazy.
|
|
34
|
+
"""
|
|
35
|
+
global _session_factory
|
|
36
|
+
if _session_factory is None:
|
|
37
|
+
_session_factory = async_sessionmaker(
|
|
38
|
+
autocommit=False,
|
|
39
|
+
autoflush=False,
|
|
40
|
+
bind=get_engine(),
|
|
41
|
+
class_=AsyncSession,
|
|
42
|
+
)
|
|
43
|
+
return _session_factory
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def get_async_db_session() -> AsyncGenerator[AsyncSession, None]:
|
|
47
|
+
"""Generador de sesiones asíncronas delegando el ciclo de vida al context manager."""
|
|
48
|
+
factory = get_session_factory()
|
|
49
|
+
async with factory() as db:
|
|
50
|
+
yield db
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def reset_sqlalchemy_engine() -> None:
|
|
54
|
+
"""
|
|
55
|
+
Cierra el pool de conexiones actual y recrea el engine de forma lazy.
|
|
56
|
+
Crítico para inicializar asíncronamente workers (ej. RabbitMQ, Celery)
|
|
57
|
+
y evitar el error de 'Task attached to a different loop'.
|
|
58
|
+
"""
|
|
59
|
+
global _engine
|
|
60
|
+
global _session_factory
|
|
61
|
+
|
|
62
|
+
if _engine is not None:
|
|
63
|
+
await _engine.dispose()
|
|
64
|
+
|
|
65
|
+
_engine = None
|
|
66
|
+
_session_factory = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Workers para tareas en segundo plano o consumidores asíncronos."""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Worker asíncrono para RabbitMQ.
|
|
3
|
+
Resuelve el problema de la duplicación de conexiones (SQLAlchemy y MongoDB)
|
|
4
|
+
garantizando que todos los pools se inicializan *dentro* del nuevo Event Loop.
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import typing as t
|
|
9
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
10
|
+
|
|
11
|
+
from hexcore.config import LazyConfig
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("hexcore.workers.rabbitmq")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@asynccontextmanager
|
|
17
|
+
async def setup_sqlalchemy():
|
|
18
|
+
"""Context manager para inicializar y limpiar conexiones SQL en el worker."""
|
|
19
|
+
from hexcore.infrastructure.repositories.orms.sqlalchemy.session import reset_sqlalchemy_engine, engine
|
|
20
|
+
|
|
21
|
+
# 1. Resetear el pool para atarlo a ESTE event loop
|
|
22
|
+
logger.info("[Worker Lifecycle] Inicializando engine de SQLAlchemy...")
|
|
23
|
+
await reset_sqlalchemy_engine()
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
yield
|
|
27
|
+
finally:
|
|
28
|
+
logger.info("[Worker Lifecycle] Cerrando conexiones de SQLAlchemy...")
|
|
29
|
+
await engine.dispose()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def setup_mongodb():
|
|
34
|
+
"""Context manager para inicializar Beanie/MongoDB en el worker."""
|
|
35
|
+
from hexcore.infrastructure.repositories.orms.beanie.utils import init_beanie_documents
|
|
36
|
+
|
|
37
|
+
logger.info("[Worker Lifecycle] Inicializando MongoDB (Beanie)...")
|
|
38
|
+
await init_beanie_documents()
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
# Beanie/Motor no requiere un teardown estricto manual ya que se cierra
|
|
44
|
+
# con el event loop o GC, pero aquí podríamos cerrar el AsyncMongoClient si tuvieramos la ref.
|
|
45
|
+
logger.info("[Worker Lifecycle] MongoDB (Beanie) ciclo finalizado.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@asynccontextmanager
|
|
49
|
+
async def connect_rabbitmq(amqp_url: str):
|
|
50
|
+
"""Context manager para manejar la conexión de aio-pika."""
|
|
51
|
+
import aio_pika
|
|
52
|
+
|
|
53
|
+
logger.info("[Worker Lifecycle] Conectando a RabbitMQ...")
|
|
54
|
+
connection = await aio_pika.connect_robust(amqp_url)
|
|
55
|
+
try:
|
|
56
|
+
yield connection
|
|
57
|
+
finally:
|
|
58
|
+
logger.info("[Worker Lifecycle] Cerrando conexión de RabbitMQ...")
|
|
59
|
+
await connection.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def run_worker(
|
|
63
|
+
amqp_url: str,
|
|
64
|
+
event_bus_factory: t.Callable[["aio_pika.abc.AbstractRobustConnection"], t.Any],
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Punto de entrada robusto para arrancar el Worker de RabbitMQ.
|
|
68
|
+
Asegura que no haya "magia negra" (fugas de conexiones o error de threads).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
amqp_url: URL de RabbitMQ (ej: amqp://guest:guest@localhost/)
|
|
72
|
+
event_bus_factory: Función/Callback que recibe la conexión aio-pika e
|
|
73
|
+
instancia un RabbitMQEventBus con sus handlers registrados.
|
|
74
|
+
"""
|
|
75
|
+
logger.info("Iniciando Worker de EventBus...")
|
|
76
|
+
|
|
77
|
+
async with AsyncExitStack() as stack:
|
|
78
|
+
# 1. Setup DataBases (SQL y NoSQL) atados a ESTE Loop
|
|
79
|
+
await stack.enter_async_context(setup_sqlalchemy())
|
|
80
|
+
await stack.enter_async_context(setup_mongodb())
|
|
81
|
+
|
|
82
|
+
# 2. Setup RabbitMQ
|
|
83
|
+
rmq_conn = await stack.enter_async_context(connect_rabbitmq(amqp_url))
|
|
84
|
+
|
|
85
|
+
# 3. Construir e inicializar el bus
|
|
86
|
+
event_bus = event_bus_factory(rmq_conn)
|
|
87
|
+
|
|
88
|
+
# 4. Comenzar a consumir
|
|
89
|
+
await event_bus.start_consuming()
|
|
90
|
+
|
|
91
|
+
# 5. Mantener vivo (forever)
|
|
92
|
+
logger.info("Worker corriendo eternamente. Presiona Ctrl+C para salir.")
|
|
93
|
+
await asyncio.Future() # bloquea infinitamente hasta que se envíe una señal
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexcore
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Núcleo reutilizable para proyectos Python con arquitectura hexagonal y event handling. Provee abstracciones, utilidades y contratos para DDD, eventos y desacoplamiento de infraestructura.
|
|
5
5
|
Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,6 +20,8 @@ Requires-Dist: typer>=0.17.3
|
|
|
20
20
|
Requires-Dist: ruff>=0.12.11
|
|
21
21
|
Provides-Extra: procrastinate
|
|
22
22
|
Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
|
|
23
|
+
Provides-Extra: rabbitmq
|
|
24
|
+
Requires-Dist: aio-pika>=9.4.0; extra == "rabbitmq"
|
|
23
25
|
Dynamic: license-file
|
|
24
26
|
|
|
25
27
|
# HexCore [](https://pepy.tech/projects/hexcore)
|
|
@@ -57,6 +57,7 @@ hexcore/infrastructure/cqrs/__init__.py
|
|
|
57
57
|
hexcore/infrastructure/cqrs/middlewares.py
|
|
58
58
|
hexcore/infrastructure/cqrs/procrastinate.py
|
|
59
59
|
hexcore/infrastructure/cqrs/pydantic_serializer.py
|
|
60
|
+
hexcore/infrastructure/cqrs/rabbitmq.py
|
|
60
61
|
hexcore/infrastructure/events/__init__.py
|
|
61
62
|
hexcore/infrastructure/events/events_backends/__init__.py
|
|
62
63
|
hexcore/infrastructure/events/events_backends/memory.py
|
|
@@ -74,6 +75,8 @@ hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py
|
|
|
74
75
|
hexcore/infrastructure/uow/__init__.py
|
|
75
76
|
hexcore/infrastructure/uow/decorators.py
|
|
76
77
|
hexcore/infrastructure/uow/helpers.py
|
|
78
|
+
hexcore/infrastructure/workers/__init__.py
|
|
79
|
+
hexcore/infrastructure/workers/rabbitmq_worker.py
|
|
77
80
|
scripts/__init__.py
|
|
78
81
|
scripts/main.py
|
|
79
82
|
tests/conftest.py
|
|
@@ -84,6 +87,7 @@ tests/test_cqrs.py
|
|
|
84
87
|
tests/test_domain_service_query.py
|
|
85
88
|
tests/test_infrastructure_query_path.py
|
|
86
89
|
tests/test_query_field_validation.py
|
|
90
|
+
tests/test_rabbitmq_bus.py
|
|
87
91
|
tests/test_repositories_utils.py
|
|
88
92
|
tests/test_uow_session_regression.py
|
|
89
93
|
tests/test_use_cases_query.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexcore"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.2.0"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name="David Latosefki (Indroic)", email="indroic@outlook.com" },
|
|
6
6
|
]
|
|
@@ -26,6 +26,7 @@ license-files = ["LICEN[CS]E*"]
|
|
|
26
26
|
|
|
27
27
|
[project.optional-dependencies]
|
|
28
28
|
procrastinate = ["procrastinate>=3.0.0"]
|
|
29
|
+
rabbitmq = ["aio-pika>=9.4.0"]
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
[project.scripts]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import pytest
|
|
4
|
+
import sys
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock, ANY
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
# Mockear aio_pika globalmente para los tests (porque es una dependencia opcional)
|
|
9
|
+
sys.modules["aio_pika"] = MagicMock()
|
|
10
|
+
sys.modules["aio_pika"].Message = MagicMock
|
|
11
|
+
sys.modules["aio_pika"].ExchangeType = MagicMock()
|
|
12
|
+
|
|
13
|
+
from hexcore.domain.events import DomainEvent
|
|
14
|
+
from hexcore.infrastructure.cqrs.rabbitmq import RabbitMQEventBus
|
|
15
|
+
from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DummyEvent(DomainEvent):
|
|
19
|
+
my_data: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def aio_pika_mocks():
|
|
24
|
+
"""Retorna mocks para conexión, canal y exchange."""
|
|
25
|
+
connection = AsyncMock()
|
|
26
|
+
channel = AsyncMock()
|
|
27
|
+
exchange = AsyncMock()
|
|
28
|
+
queue = AsyncMock()
|
|
29
|
+
|
|
30
|
+
connection.channel.return_value = channel
|
|
31
|
+
channel.declare_exchange.return_value = exchange
|
|
32
|
+
channel.declare_queue.return_value = queue
|
|
33
|
+
|
|
34
|
+
return connection, channel, exchange, queue
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def anyio_backend():
|
|
39
|
+
return 'asyncio'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.anyio
|
|
43
|
+
async def test_rabbitmq_event_bus_publish(aio_pika_mocks):
|
|
44
|
+
connection, channel, exchange, queue = aio_pika_mocks
|
|
45
|
+
|
|
46
|
+
bus = RabbitMQEventBus(
|
|
47
|
+
connection=connection,
|
|
48
|
+
serializer=PydanticSerializer(),
|
|
49
|
+
exchange_name="test.events"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
event = DummyEvent(my_data="hello")
|
|
53
|
+
await bus.publish(event)
|
|
54
|
+
|
|
55
|
+
# Verifica que se creó el canal y el exchange (lazy setup)
|
|
56
|
+
connection.channel.assert_awaited_once()
|
|
57
|
+
channel.declare_exchange.assert_awaited_once_with(
|
|
58
|
+
"test.events",
|
|
59
|
+
ANY,
|
|
60
|
+
durable=True
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Verifica que se publicó en el exchange
|
|
64
|
+
exchange.publish.assert_awaited_once()
|
|
65
|
+
call_args = exchange.publish.call_args[0]
|
|
66
|
+
message = call_args[0]
|
|
67
|
+
|
|
68
|
+
payload = json.loads(message.body.decode("utf-8"))
|
|
69
|
+
assert payload["__data__"]["my_data"] == "hello"
|
|
70
|
+
|
|
71
|
+
# Routing key
|
|
72
|
+
kwargs = exchange.publish.call_args[1]
|
|
73
|
+
assert kwargs["routing_key"] == "DUMMY"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.anyio
|
|
77
|
+
async def test_rabbitmq_event_bus_start_consuming(aio_pika_mocks):
|
|
78
|
+
connection, channel, exchange, queue = aio_pika_mocks
|
|
79
|
+
|
|
80
|
+
bus = RabbitMQEventBus(
|
|
81
|
+
connection=connection,
|
|
82
|
+
serializer=PydanticSerializer(),
|
|
83
|
+
queue_name="test.queue"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
handler = AsyncMock()
|
|
87
|
+
bus.subscribe(DummyEvent, handler)
|
|
88
|
+
|
|
89
|
+
await bus.start_consuming()
|
|
90
|
+
|
|
91
|
+
# Debe haber hecho bind
|
|
92
|
+
queue.bind.assert_awaited_once_with(exchange, routing_key="DUMMY")
|
|
93
|
+
|
|
94
|
+
# Debe haber iniciado a consumir
|
|
95
|
+
queue.consume.assert_awaited_once_with(bus._handle_message)
|
|
@@ -59,8 +59,8 @@ def test_get_async_db_session_no_manual_rollback_on_error():
|
|
|
59
59
|
session.rollback = AsyncMock()
|
|
60
60
|
|
|
61
61
|
with patch(
|
|
62
|
-
"hexcore.infrastructure.repositories.orms.sqlalchemy.session.
|
|
63
|
-
return_value=_DummySessionContext(session),
|
|
62
|
+
"hexcore.infrastructure.repositories.orms.sqlalchemy.session.get_session_factory",
|
|
63
|
+
return_value=lambda: _DummySessionContext(session),
|
|
64
64
|
):
|
|
65
65
|
session_generator = cast(AsyncGenerator[Any, None], get_async_db_session())
|
|
66
66
|
yielded = await anext(session_generator)
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
from collections.abc import AsyncGenerator
|
|
2
|
-
|
|
3
|
-
from sqlalchemy.ext.asyncio import (
|
|
4
|
-
AsyncEngine,
|
|
5
|
-
AsyncSession,
|
|
6
|
-
async_sessionmaker,
|
|
7
|
-
create_async_engine,
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
from hexcore.config import LazyConfig
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# 1. CREAR EL ENGINE ASÍNCRONO DE SQLAlchemy
|
|
14
|
-
# Usamos create_async_engine en lugar de create_engine.
|
|
15
|
-
engine: AsyncEngine = create_async_engine(
|
|
16
|
-
LazyConfig.get_config().async_sql_database_url,
|
|
17
|
-
# `echo=True` es útil para depuración, ya que imprime todas las sentencias SQL.
|
|
18
|
-
# Desactívalo en producción.
|
|
19
|
-
# echo=True,
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
# 2. CREAR UNA FACTORÍA DE SESIONES ASÍNCRONAS
|
|
23
|
-
# Usamos async_sessionmaker y especificamos la clase AsyncSession.
|
|
24
|
-
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
|
25
|
-
autocommit=False,
|
|
26
|
-
autoflush=False,
|
|
27
|
-
bind=engine,
|
|
28
|
-
class_=AsyncSession,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
async def get_async_db_session() -> AsyncGenerator[AsyncSession, None]:
|
|
33
|
-
"""Generador de sesiones asíncronas delegando el ciclo de vida al context manager."""
|
|
34
|
-
async with AsyncSessionLocal() as db:
|
|
35
|
-
yield db
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py
RENAMED
|
File without changes
|
{hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|