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.
Files changed (96) hide show
  1. {hexcore-2.1.0/hexcore.egg-info → hexcore-2.2.0}/PKG-INFO +3 -1
  2. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/api/utils.py +4 -2
  3. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cqrs/middlewares.py +3 -2
  4. hexcore-2.2.0/hexcore/infrastructure/cqrs/rabbitmq.py +182 -0
  5. hexcore-2.2.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +66 -0
  6. hexcore-2.2.0/hexcore/infrastructure/workers/__init__.py +1 -0
  7. hexcore-2.2.0/hexcore/infrastructure/workers/rabbitmq_worker.py +93 -0
  8. {hexcore-2.1.0 → hexcore-2.2.0/hexcore.egg-info}/PKG-INFO +3 -1
  9. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/SOURCES.txt +4 -0
  10. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/requires.txt +3 -0
  11. {hexcore-2.1.0 → hexcore-2.2.0}/pyproject.toml +2 -1
  12. hexcore-2.2.0/tests/test_rabbitmq_bus.py +95 -0
  13. {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_uow_session_regression.py +2 -2
  14. hexcore-2.1.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -35
  15. {hexcore-2.1.0 → hexcore-2.2.0}/LICENSE +0 -0
  16. {hexcore-2.1.0 → hexcore-2.2.0}/MANIFEST.in +0 -0
  17. {hexcore-2.1.0 → hexcore-2.2.0}/README.md +0 -0
  18. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/__init__.py +0 -0
  19. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/__main__.py +0 -0
  20. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/__init__.py +0 -0
  21. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/__init__.py +0 -0
  22. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/adapters.py +0 -0
  23. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/config.py +0 -0
  24. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/factory.py +0 -0
  25. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
  26. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/pipeline.py +0 -0
  27. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/cqrs/registry.py +0 -0
  28. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/dtos/__init__.py +0 -0
  29. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/dtos/base.py +0 -0
  30. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/dtos/query.py +0 -0
  31. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/use_cases/__init__.py +0 -0
  32. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/use_cases/base.py +0 -0
  33. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/application/use_cases/query.py +0 -0
  34. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/config.py +0 -0
  35. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/__init__.py +0 -0
  36. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/auth/__init__.py +0 -0
  37. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/auth/permissions.py +0 -0
  38. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/auth/value_objects.py +0 -0
  39. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/base.py +0 -0
  40. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/__init__.py +0 -0
  41. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/buses.py +0 -0
  42. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/commands.py +0 -0
  43. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  44. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/handlers.py +0 -0
  45. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/middleware.py +0 -0
  46. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/queries.py +0 -0
  47. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/cqrs/serializer.py +0 -0
  48. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/events.py +0 -0
  49. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/exceptions.py +0 -0
  50. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/repositories.py +0 -0
  51. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/services.py +0 -0
  52. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/domain/uow.py +0 -0
  53. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/__init__.py +0 -0
  54. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/api/__init__.py +0 -0
  55. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  56. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  57. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  58. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  59. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cli.py +0 -0
  60. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  61. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  62. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  63. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/events/__init__.py +0 -0
  64. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  65. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  66. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  67. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/base.py +0 -0
  68. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  69. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
  70. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  71. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  72. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  73. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  74. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  75. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/repositories/utils.py +0 -0
  76. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/uow/__init__.py +0 -0
  77. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  78. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  79. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/py.typed +0 -0
  80. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore/types.py +0 -0
  81. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/dependency_links.txt +0 -0
  82. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/entry_points.txt +0 -0
  83. {hexcore-2.1.0 → hexcore-2.2.0}/hexcore.egg-info/top_level.txt +0 -0
  84. {hexcore-2.1.0 → hexcore-2.2.0}/scripts/__init__.py +0 -0
  85. {hexcore-2.1.0 → hexcore-2.2.0}/scripts/main.py +0 -0
  86. {hexcore-2.1.0 → hexcore-2.2.0}/setup.cfg +0 -0
  87. {hexcore-2.1.0 → hexcore-2.2.0}/tests/conftest.py +0 -0
  88. {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_basic.py +0 -0
  89. {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_beanie_query_utils.py +0 -0
  90. {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_config_loading.py +0 -0
  91. {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_cqrs.py +0 -0
  92. {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_domain_service_query.py +0 -0
  93. {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_infrastructure_query_path.py +0 -0
  94. {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_query_field_validation.py +0 -0
  95. {hexcore-2.1.0 → hexcore-2.2.0}/tests/test_repositories_utils.py +0 -0
  96. {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.1.0
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 [![PyPI Downloads](https://static.pepy.tech/personalized-badge/hexcore?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](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
- AsyncSessionLocal,
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
- async with AsyncSessionLocal() as session:
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 AsyncSessionLocal
127
- return SqlAlchemyUnitOfWork(session=AsyncSessionLocal())
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.1.0
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 [![PyPI Downloads](https://static.pepy.tech/personalized-badge/hexcore?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](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
@@ -12,3 +12,6 @@ ruff>=0.12.11
12
12
 
13
13
  [procrastinate]
14
14
  procrastinate>=3.0.0
15
+
16
+ [rabbitmq]
17
+ aio-pika>=9.4.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexcore"
3
- version = "2.1.0"
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.AsyncSessionLocal",
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