hexcore 2.3.0__tar.gz → 2.4.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 (107) hide show
  1. {hexcore-2.3.0/hexcore.egg-info → hexcore-2.4.0}/PKG-INFO +31 -24
  2. {hexcore-2.3.0 → hexcore-2.4.0}/README.md +28 -23
  3. hexcore-2.4.0/hexcore/infrastructure/cqrs/postgres_bus.py +126 -0
  4. hexcore-2.4.0/hexcore/infrastructure/cqrs/redis_bus.py +143 -0
  5. hexcore-2.4.0/hexcore/infrastructure/task_queues/__init__.py +3 -0
  6. hexcore-2.4.0/hexcore/infrastructure/task_queues/celery_adapter.py +83 -0
  7. hexcore-2.4.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +61 -0
  8. {hexcore-2.3.0 → hexcore-2.4.0/hexcore.egg-info}/PKG-INFO +31 -24
  9. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/SOURCES.txt +8 -0
  10. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/requires.txt +3 -0
  11. {hexcore-2.3.0 → hexcore-2.4.0}/pyproject.toml +2 -1
  12. hexcore-2.4.0/tests/test_postgres_bus.py +73 -0
  13. hexcore-2.4.0/tests/test_redis_bus.py +75 -0
  14. hexcore-2.4.0/tests/test_task_queues_adapters.py +82 -0
  15. {hexcore-2.3.0 → hexcore-2.4.0}/LICENSE +0 -0
  16. {hexcore-2.3.0 → hexcore-2.4.0}/MANIFEST.in +0 -0
  17. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/__init__.py +0 -0
  18. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/__main__.py +0 -0
  19. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/__init__.py +0 -0
  20. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/__init__.py +0 -0
  21. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/adapters.py +0 -0
  22. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/config.py +0 -0
  23. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/factory.py +0 -0
  24. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
  25. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/pipeline.py +0 -0
  26. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/registry.py +0 -0
  27. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/dtos/__init__.py +0 -0
  28. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/dtos/base.py +0 -0
  29. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/dtos/query.py +0 -0
  30. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/use_cases/__init__.py +0 -0
  31. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/use_cases/base.py +0 -0
  32. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/use_cases/query.py +0 -0
  33. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/config.py +0 -0
  34. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/__init__.py +0 -0
  35. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/auth/__init__.py +0 -0
  36. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/auth/permissions.py +0 -0
  37. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/auth/value_objects.py +0 -0
  38. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/base.py +0 -0
  39. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/__init__.py +0 -0
  40. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/buses.py +0 -0
  41. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/commands.py +0 -0
  42. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/decorators.py +0 -0
  43. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  44. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/handlers.py +0 -0
  45. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/middleware.py +0 -0
  46. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/queries.py +0 -0
  47. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/serializer.py +0 -0
  48. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/task_queues.py +0 -0
  49. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/events.py +0 -0
  50. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/exceptions.py +0 -0
  51. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/repositories.py +0 -0
  52. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/services.py +0 -0
  53. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/uow.py +0 -0
  54. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/__init__.py +0 -0
  55. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/api/__init__.py +0 -0
  56. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/api/utils.py +0 -0
  57. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  58. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  59. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  60. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  61. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cli.py +0 -0
  62. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  63. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
  64. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  65. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  66. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  67. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/events/__init__.py +0 -0
  68. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  69. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  70. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  71. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/base.py +0 -0
  72. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  73. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
  74. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  75. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  76. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  77. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  78. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  79. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  80. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/utils.py +0 -0
  81. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/__init__.py +0 -0
  82. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  83. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  84. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/__init__.py +0 -0
  85. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/consumer.py +0 -0
  86. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
  87. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/py.typed +0 -0
  88. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/types.py +0 -0
  89. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/dependency_links.txt +0 -0
  90. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/entry_points.txt +0 -0
  91. {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/top_level.txt +0 -0
  92. {hexcore-2.3.0 → hexcore-2.4.0}/scripts/__init__.py +0 -0
  93. {hexcore-2.3.0 → hexcore-2.4.0}/scripts/main.py +0 -0
  94. {hexcore-2.3.0 → hexcore-2.4.0}/setup.cfg +0 -0
  95. {hexcore-2.3.0 → hexcore-2.4.0}/tests/conftest.py +0 -0
  96. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_basic.py +0 -0
  97. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_beanie_query_utils.py +0 -0
  98. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_config_loading.py +0 -0
  99. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_cqrs.py +0 -0
  100. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_domain_service_query.py +0 -0
  101. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_infrastructure_query_path.py +0 -0
  102. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_query_field_validation.py +0 -0
  103. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_rabbitmq_bus.py +0 -0
  104. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_repositories_utils.py +0 -0
  105. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_smart_routing.py +0 -0
  106. {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_uow_session_regression.py +0 -0
  107. {hexcore-2.3.0 → hexcore-2.4.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.0
3
+ Version: 2.4.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
@@ -22,6 +22,8 @@ Provides-Extra: procrastinate
22
22
  Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
23
23
  Provides-Extra: rabbitmq
24
24
  Requires-Dist: aio-pika>=9.4.0; extra == "rabbitmq"
25
+ Provides-Extra: celery
26
+ Requires-Dist: celery>=5.4.0; extra == "celery"
25
27
  Dynamic: license-file
26
28
 
27
29
  # 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)
@@ -516,23 +518,39 @@ class ProcrastinateEnqueuer(ITaskEnqueuer):
516
518
  await process_generic_task.defer_async(task_name=task_name, payload=payload)
517
519
  ```
518
520
 
519
- #### 3. Configurar tus Buses (Enrutamiento Automático)
521
+ #### 2. Configurar tus Buses con un Adaptador Oficial
520
522
 
521
- Al inyectar tu enqueuer en los buses estándar de memoria en tu API, estos adquieren la habilidad de enrutamiento inteligente. (Si usas un comando decorado pero no inyectas un enqueuer, HexCore levantará una excepción tempranamente).
523
+ HexCore provee adaptadores *plug & play* para **Celery** y **Procrastinate**. Simplemente importa el enqueuer, pásale tu app y configúralo en los buses de memoria.
522
524
 
523
- ```python
524
- from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus, InMemoryEventBus
525
+ Si además deseas persistencia o distribución de Eventos entre múltiples workers/servidores (Pub/Sub), puedes cambiar el `InMemoryEventBus` por `RedisEventBus`, `PostgresEventBus` o `RabbitMQEventBus`:
525
526
 
526
- enqueuer = ProcrastinateEnqueuer()
527
+ ```python
528
+ from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus
529
+ from hexcore.infrastructure.task_queues.celery_adapter import CeleryEnqueuer
530
+ from hexcore.infrastructure.cqrs.redis_bus import RedisEventBus
531
+ from celery import Celery
532
+ import redis.asyncio as redis
533
+
534
+ # 1. Adaptador de Task Queue (Para Comandos asíncronos y Event Handlers asíncronos)
535
+ app = Celery("my_app", broker="redis://localhost:6379/0")
536
+ enqueuer = CeleryEnqueuer(app)
527
537
  serializer = PydanticSerializer()
528
538
 
529
- # El bus evalúa: ¿Tiene el comando @background_command? Si es así, usa el enqueuer.
530
539
  command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
531
540
 
532
- # El bus evalúa suscriptores: ¿Tienen @background_handler? Si es así, usa el enqueuer.
533
- event_bus = InMemoryEventBus(enqueuer=enqueuer, serializer=serializer)
541
+ # 2. Event Bus (Para enviar los Eventos por la red)
542
+ redis_client = redis.from_url("redis://localhost:6379/0")
543
+ event_bus = RedisEventBus(
544
+ redis_client=redis_client,
545
+ serializer=serializer,
546
+ stream_name="hexcore:events",
547
+ group_name="api_workers",
548
+ enqueuer=enqueuer # <-- Importante para inyectarle la habilidad de Smart Routing
549
+ )
534
550
  ```
535
551
 
552
+ > **Tip:** También dispones de `PostgresEventBus(pool, serializer, channel_name)` que usa `LISTEN/NOTIFY` nativo si quieres 0 dependencias externas aparte de tu BD de siempre.
553
+
536
554
  #### 4. Ejecutar tareas genéricas
537
555
 
538
556
  Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
@@ -548,10 +566,11 @@ await enqueuer.enqueue_task(
548
566
 
549
567
  #### 5. Levantar el Worker (Consumidor Universal)
550
568
 
551
- En el entrypoint de tu worker (ej. Celery o Procrastinate), usa el `CQRSConsumer` de HexCore para deserializar y ejecutar los payloads interceptados. El consumidor usa resolución dinámica para invocar la función correcta automáticamente.
569
+ En el entrypoint de tu worker, usa la función utilitaria `register_hexcore_celery_tasks` para autoconfigurar las rutas en una sola línea:
552
570
 
553
571
  ```python
554
572
  from hexcore.infrastructure.workers.consumer import CQRSConsumer
573
+ from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
555
574
 
556
575
  consumer = CQRSConsumer(
557
576
  command_bus=command_bus, # Tu CommandBus configurado
@@ -559,20 +578,8 @@ consumer = CQRSConsumer(
559
578
  serializer=serializer
560
579
  )
561
580
 
562
- @app.task(name="process_cqrs_command")
563
- async def process_cqrs_command(payload: dict):
564
- # HexCore deserializa y ejecuta el Command usando el CommandBus local
565
- await consumer.process_command(payload)
566
-
567
- @app.task(name="process_cqrs_handler")
568
- async def process_cqrs_handler(handler_name: str, payload: dict):
569
- # HexCore resuelve y ejecuta exclusivamente el Event Handler asíncrono
570
- await consumer.process_handler(handler_name, payload)
571
-
572
- @app.task(name="process_generic_task")
573
- async def process_generic_task(task_name: str, payload: dict):
574
- # HexCore resuelve e inyecta los kwargs a la función pura
575
- await consumer.process_task(task_name, payload)
581
+ # ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
582
+ register_hexcore_celery_tasks(app, consumer)
576
583
  ```
577
584
 
578
585
  ---
@@ -490,23 +490,39 @@ class ProcrastinateEnqueuer(ITaskEnqueuer):
490
490
  await process_generic_task.defer_async(task_name=task_name, payload=payload)
491
491
  ```
492
492
 
493
- #### 3. Configurar tus Buses (Enrutamiento Automático)
493
+ #### 2. Configurar tus Buses con un Adaptador Oficial
494
494
 
495
- Al inyectar tu enqueuer en los buses estándar de memoria en tu API, estos adquieren la habilidad de enrutamiento inteligente. (Si usas un comando decorado pero no inyectas un enqueuer, HexCore levantará una excepción tempranamente).
495
+ HexCore provee adaptadores *plug & play* para **Celery** y **Procrastinate**. Simplemente importa el enqueuer, pásale tu app y configúralo en los buses de memoria.
496
496
 
497
- ```python
498
- from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus, InMemoryEventBus
497
+ Si además deseas persistencia o distribución de Eventos entre múltiples workers/servidores (Pub/Sub), puedes cambiar el `InMemoryEventBus` por `RedisEventBus`, `PostgresEventBus` o `RabbitMQEventBus`:
499
498
 
500
- enqueuer = ProcrastinateEnqueuer()
499
+ ```python
500
+ from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus
501
+ from hexcore.infrastructure.task_queues.celery_adapter import CeleryEnqueuer
502
+ from hexcore.infrastructure.cqrs.redis_bus import RedisEventBus
503
+ from celery import Celery
504
+ import redis.asyncio as redis
505
+
506
+ # 1. Adaptador de Task Queue (Para Comandos asíncronos y Event Handlers asíncronos)
507
+ app = Celery("my_app", broker="redis://localhost:6379/0")
508
+ enqueuer = CeleryEnqueuer(app)
501
509
  serializer = PydanticSerializer()
502
510
 
503
- # El bus evalúa: ¿Tiene el comando @background_command? Si es así, usa el enqueuer.
504
511
  command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
505
512
 
506
- # El bus evalúa suscriptores: ¿Tienen @background_handler? Si es así, usa el enqueuer.
507
- event_bus = InMemoryEventBus(enqueuer=enqueuer, serializer=serializer)
513
+ # 2. Event Bus (Para enviar los Eventos por la red)
514
+ redis_client = redis.from_url("redis://localhost:6379/0")
515
+ event_bus = RedisEventBus(
516
+ redis_client=redis_client,
517
+ serializer=serializer,
518
+ stream_name="hexcore:events",
519
+ group_name="api_workers",
520
+ enqueuer=enqueuer # <-- Importante para inyectarle la habilidad de Smart Routing
521
+ )
508
522
  ```
509
523
 
524
+ > **Tip:** También dispones de `PostgresEventBus(pool, serializer, channel_name)` que usa `LISTEN/NOTIFY` nativo si quieres 0 dependencias externas aparte de tu BD de siempre.
525
+
510
526
  #### 4. Ejecutar tareas genéricas
511
527
 
512
528
  Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
@@ -522,10 +538,11 @@ await enqueuer.enqueue_task(
522
538
 
523
539
  #### 5. Levantar el Worker (Consumidor Universal)
524
540
 
525
- En el entrypoint de tu worker (ej. Celery o Procrastinate), usa el `CQRSConsumer` de HexCore para deserializar y ejecutar los payloads interceptados. El consumidor usa resolución dinámica para invocar la función correcta automáticamente.
541
+ En el entrypoint de tu worker, usa la función utilitaria `register_hexcore_celery_tasks` para autoconfigurar las rutas en una sola línea:
526
542
 
527
543
  ```python
528
544
  from hexcore.infrastructure.workers.consumer import CQRSConsumer
545
+ from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
529
546
 
530
547
  consumer = CQRSConsumer(
531
548
  command_bus=command_bus, # Tu CommandBus configurado
@@ -533,20 +550,8 @@ consumer = CQRSConsumer(
533
550
  serializer=serializer
534
551
  )
535
552
 
536
- @app.task(name="process_cqrs_command")
537
- async def process_cqrs_command(payload: dict):
538
- # HexCore deserializa y ejecuta el Command usando el CommandBus local
539
- await consumer.process_command(payload)
540
-
541
- @app.task(name="process_cqrs_handler")
542
- async def process_cqrs_handler(handler_name: str, payload: dict):
543
- # HexCore resuelve y ejecuta exclusivamente el Event Handler asíncrono
544
- await consumer.process_handler(handler_name, payload)
545
-
546
- @app.task(name="process_generic_task")
547
- async def process_generic_task(task_name: str, payload: dict):
548
- # HexCore resuelve e inyecta los kwargs a la función pura
549
- await consumer.process_task(task_name, payload)
553
+ # ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
554
+ register_hexcore_celery_tasks(app, consumer)
550
555
  ```
551
556
 
552
557
  ---
@@ -0,0 +1,126 @@
1
+ """
2
+ Event Bus basado en PostgreSQL LISTEN / NOTIFY.
3
+ Ligero, rápido y no requiere infraestructura adicional si ya usas PostgreSQL.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import typing as t
11
+
12
+ from hexcore.domain.cqrs.buses import IEventBus
13
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
14
+ from hexcore.domain.events import DomainEvent
15
+
16
+ if t.TYPE_CHECKING:
17
+ from asyncpg import Pool, Connection
18
+ from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class PostgresEventBus(IEventBus):
25
+ """
26
+ Implementación de IEventBus utilizando PostgreSQL LISTEN/NOTIFY.
27
+ Nota: LISTEN/NOTIFY no persiste los eventos una vez entregados.
28
+ Ideal para eventos efímeros o arquitecturas más simples.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ pool: "Pool",
34
+ serializer: "PydanticSerializer",
35
+ channel_name: str,
36
+ enqueuer: ITaskEnqueuer | None = None,
37
+ ) -> None:
38
+ self.pool = pool
39
+ self._serializer = serializer
40
+ self.channel_name = channel_name
41
+ self.enqueuer = enqueuer
42
+
43
+ self._handlers: dict[type[DomainEvent], list[t.Callable[..., t.Any]]] = {}
44
+ self._event_types_by_name: dict[str, type[DomainEvent]] = {}
45
+ self._stop_event = asyncio.Event()
46
+ self._listener_connection: "Connection | None" = None
47
+
48
+ def subscribe(self, event_type: type[DomainEvent], handler: t.Callable[..., t.Any]) -> None:
49
+ if event_type not in self._handlers:
50
+ self._handlers[event_type] = []
51
+ self._handlers[event_type].append(handler)
52
+
53
+ fqn = f"{event_type.__module__}.{event_type.__qualname__}"
54
+ self._event_types_by_name[fqn] = event_type
55
+
56
+ async def publish(self, event: DomainEvent) -> None:
57
+ payload = self._serializer.serialize(event)
58
+ json_payload = json.dumps(payload)
59
+
60
+ async with self.pool.acquire() as conn:
61
+ # En PostgreSQL, pg_notify recibe el canal y el payload en texto.
62
+ await conn.execute("SELECT pg_notify($1, $2)", self.channel_name, json_payload)
63
+
64
+ async def start_consuming(self) -> None:
65
+ """
66
+ Inicia el consumo adquiriendo una conexión dedicada y escuchando el canal.
67
+ """
68
+ self._stop_event.clear()
69
+
70
+ # Adquirir una conexión persistente para el listener
71
+ self._listener_connection = await self.pool.acquire()
72
+ try:
73
+ await self._listener_connection.add_listener(self.channel_name, self._handle_notify)
74
+ logger.info(f"[*] PostgresEventBus listening on channel '{self.channel_name}'")
75
+
76
+ # Bloquear hasta que alguien llame a stop()
77
+ await self._stop_event.wait()
78
+
79
+ except asyncio.CancelledError:
80
+ pass
81
+ finally:
82
+ if self._listener_connection:
83
+ try:
84
+ await self._listener_connection.remove_listener(self.channel_name, self._handle_notify)
85
+ except Exception as e:
86
+ logger.warning(f"Error removing postgres listener: {e}")
87
+ await self.pool.release(self._listener_connection)
88
+ self._listener_connection = None
89
+
90
+ def _handle_notify(self, connection: "Connection", pid: int, channel: str, payload: str) -> None:
91
+ """
92
+ Callback invocada por asyncpg cuando recibe un NOTIFY.
93
+ Dado que es síncrona/concurrente desde el driver, delegamos la carga real
94
+ a un asyncio.create_task para evitar bloquear la conexión.
95
+ """
96
+ asyncio.create_task(self._process_message(payload))
97
+
98
+ async def _process_message(self, json_payload: str) -> None:
99
+ try:
100
+ payload_dict = json.loads(json_payload)
101
+ event_name = payload_dict.get("__type__")
102
+
103
+ event_type = self._event_types_by_name.get(event_name)
104
+ if not event_type:
105
+ return
106
+
107
+ event = self._serializer.deserialize(payload_dict)
108
+ handlers = self._handlers.get(event_type, [])
109
+
110
+ for handler in handlers:
111
+ is_background = getattr(handler, "__cqrs_background_handler__", False)
112
+ if is_background:
113
+ queue_name = getattr(handler, "__cqrs_queue__", "default")
114
+ if not self.enqueuer:
115
+ raise RuntimeError(f"El handler asíncrono {handler.__name__} requiere un enqueuer.")
116
+
117
+ handler_ref = getattr(handler, "__cqrs_handler_name__", f"{handler.__module__}.{handler.__name__}")
118
+ await self.enqueuer.enqueue_handler(handler_ref, payload_dict, queue_name)
119
+ else:
120
+ await handler(event)
121
+
122
+ except Exception as e:
123
+ logger.error(f"Error parseando o ejecutando evento de PostgreSQL NOTIFY: {e}")
124
+
125
+ async def stop(self) -> None:
126
+ self._stop_event.set()
@@ -0,0 +1,143 @@
1
+ """
2
+ Event Bus basado en Redis Streams.
3
+ Soporta alta concurrencia mediante Consumer Groups.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import typing as t
11
+ import uuid
12
+
13
+ from hexcore.domain.cqrs.buses import IEventBus
14
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
15
+ from hexcore.domain.events import DomainEvent
16
+
17
+ if t.TYPE_CHECKING:
18
+ from redis.asyncio import Redis
19
+ from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
20
+
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class RedisEventBus(IEventBus):
26
+ """
27
+ Implementación de IEventBus usando Redis Streams.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ redis_client: "Redis",
33
+ serializer: "PydanticSerializer",
34
+ stream_name: str,
35
+ group_name: str,
36
+ consumer_name: str | None = None,
37
+ enqueuer: ITaskEnqueuer | None = None,
38
+ ) -> None:
39
+ self.redis = redis_client
40
+ self._serializer = serializer
41
+ self.stream_name = stream_name
42
+ self.group_name = group_name
43
+ self.consumer_name = consumer_name or f"consumer-{uuid.uuid4()}"
44
+ self.enqueuer = enqueuer
45
+
46
+ self._handlers: dict[type[DomainEvent], list[t.Callable[..., t.Any]]] = {}
47
+ self._event_types_by_name: dict[str, type[DomainEvent]] = {}
48
+ self._stop_event = asyncio.Event()
49
+
50
+ def subscribe(self, event_type: type[DomainEvent], handler: t.Callable[..., t.Any]) -> None:
51
+ if event_type not in self._handlers:
52
+ self._handlers[event_type] = []
53
+ self._handlers[event_type].append(handler)
54
+
55
+ fqn = f"{event_type.__module__}.{event_type.__qualname__}"
56
+ self._event_types_by_name[fqn] = event_type
57
+
58
+ async def publish(self, event: DomainEvent) -> None:
59
+ payload = self._serializer.serialize(event)
60
+ # Redis streams accepts dicts with string keys and string values
61
+ json_payload = json.dumps(payload)
62
+ await self.redis.xadd(self.stream_name, {"payload": json_payload})
63
+
64
+ async def start_consuming(self) -> None:
65
+ """
66
+ Inicia el consumo utilizando Grupos de Consumidores de Redis.
67
+ """
68
+ import redis.exceptions
69
+
70
+ # Asegurar que el grupo existe, creándolo si no existe (con MKSTREAM)
71
+ try:
72
+ await self.redis.xgroup_create(self.stream_name, self.group_name, id="$", mkstream=True)
73
+ except redis.exceptions.ResponseError as e:
74
+ if "BUSYGROUP Consumer Group name already exists" not in str(e):
75
+ raise e
76
+
77
+ logger.info(f"[*] RedisEventBus consuming from stream '{self.stream_name}' (group: {self.group_name}, consumer: {self.consumer_name})")
78
+ self._stop_event.clear()
79
+
80
+ while not self._stop_event.is_set():
81
+ try:
82
+ # Bloquea hasta 1000ms esperando mensajes no leídos ('>')
83
+ messages = await self.redis.xreadgroup(
84
+ groupname=self.group_name,
85
+ consumername=self.consumer_name,
86
+ streams={self.stream_name: ">"},
87
+ count=10,
88
+ block=1000,
89
+ )
90
+
91
+ if not messages:
92
+ continue
93
+
94
+ # messages: [[b'stream_name', [(b'message_id', {b'payload': b'...'}), ...]]]
95
+ for stream, stream_messages in messages:
96
+ for message_id, message_data in stream_messages:
97
+ await self._handle_message(message_id, message_data)
98
+
99
+ except asyncio.CancelledError:
100
+ break
101
+ except Exception as e:
102
+ logger.error(f"Error procesando mensaje de Redis: {e}")
103
+ await asyncio.sleep(1)
104
+
105
+ async def _handle_message(self, message_id: bytes, message_data: dict[bytes, bytes]) -> None:
106
+ try:
107
+ payload_bytes = message_data.get(b"payload")
108
+ if not payload_bytes:
109
+ return
110
+
111
+ payload_dict = json.loads(payload_bytes)
112
+ event_name = payload_dict.get("__type__")
113
+
114
+ event_type = self._event_types_by_name.get(event_name)
115
+ if not event_type:
116
+ # Evento no registrado localmente, lo saltamos y confirmamos
117
+ await self.redis.xack(self.stream_name, self.group_name, message_id)
118
+ return
119
+
120
+ event = self._serializer.deserialize(payload_dict)
121
+ handlers = self._handlers.get(event_type, [])
122
+
123
+ # Ejecutar handlers (respetando Smart Routing)
124
+ for handler in handlers:
125
+ is_background = getattr(handler, "__cqrs_background_handler__", False)
126
+ if is_background:
127
+ queue_name = getattr(handler, "__cqrs_queue__", "default")
128
+ if not self.enqueuer:
129
+ raise RuntimeError(f"El handler asíncrono {handler.__name__} requiere un enqueuer.")
130
+
131
+ handler_ref = getattr(handler, "__cqrs_handler_name__", f"{handler.__module__}.{handler.__name__}")
132
+ await self.enqueuer.enqueue_handler(handler_ref, payload_dict, queue_name)
133
+ else:
134
+ await handler(event)
135
+
136
+ # Confirmar mensaje
137
+ await self.redis.xack(self.stream_name, self.group_name, message_id)
138
+
139
+ except Exception as e:
140
+ logger.error(f"Error parseando o ejecutando evento de Redis (ID {message_id}): {e}")
141
+
142
+ async def stop(self) -> None:
143
+ self._stop_event.set()
@@ -0,0 +1,3 @@
1
+ """
2
+ Adaptadores opcionales para Task Queues (Celery, Procrastinate).
3
+ """
@@ -0,0 +1,83 @@
1
+ """
2
+ Adaptador para Celery.
3
+ Permite encolar comandos y tareas usando Celery, y proporciona
4
+ utilidades para auto-registrar las tareas asíncronas en el Worker.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import typing as t
10
+
11
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
12
+
13
+ if t.TYPE_CHECKING:
14
+ try:
15
+ from celery import Celery
16
+ except ImportError:
17
+ Celery = t.Any
18
+
19
+ from hexcore.infrastructure.workers.consumer import CQRSConsumer
20
+
21
+
22
+ class CeleryEnqueuer(ITaskEnqueuer):
23
+ """
24
+ Adaptador que utiliza Celery para encolar comandos, eventos y tareas.
25
+ Dado que `ITaskEnqueuer` tiene métodos asíncronos y Celery `.send_task`
26
+ es síncrono (bloqueante), utiliza `asyncio.to_thread` para no congelar
27
+ el Event Loop principal (ej. FastAPI).
28
+ """
29
+
30
+ def __init__(self, app: "Celery") -> None:
31
+ self.app = app
32
+
33
+ async def enqueue_command(self, command_name: str, payload: dict[str, t.Any], queue: str) -> None:
34
+ await asyncio.to_thread(
35
+ self.app.send_task,
36
+ "hexcore.process_command",
37
+ kwargs={"payload": payload},
38
+ queue=queue,
39
+ )
40
+
41
+ async def enqueue_event(self, event_name: str, payload: dict[str, t.Any], queue: str) -> None:
42
+ # Los eventos genéricos (publish a todos los workers) rara vez se envían a Celery
43
+ # de esta forma. Normalmente se usa enqueue_handler. Se provee para completitud.
44
+ pass
45
+
46
+ async def enqueue_handler(self, handler_name: str, payload: dict[str, t.Any], queue: str) -> None:
47
+ await asyncio.to_thread(
48
+ self.app.send_task,
49
+ "hexcore.process_handler",
50
+ kwargs={"handler_name": handler_name, "payload": payload},
51
+ queue=queue,
52
+ )
53
+
54
+ async def enqueue_task(self, task_name: str, payload: dict[str, t.Any], queue: str) -> None:
55
+ await asyncio.to_thread(
56
+ self.app.send_task,
57
+ "hexcore.process_task",
58
+ kwargs={"task_name": task_name, "payload": payload},
59
+ queue=queue,
60
+ )
61
+
62
+
63
+ def register_hexcore_celery_tasks(app: "Celery", consumer: "CQRSConsumer") -> None:
64
+ """
65
+ Auto-registra las tareas base de HexCore en una aplicación Celery.
66
+ Esto permite que un worker de Celery esté listo para recibir mensajes
67
+ generados por el Smart Routing de HexCore.
68
+
69
+ Dado que las tareas de Celery son síncronas y el Consumer de HexCore es
70
+ asíncrono, se envuelven en `asyncio.run()`.
71
+ """
72
+
73
+ @app.task(name="hexcore.process_command", bind=True)
74
+ def process_command(self: t.Any, payload: dict[str, t.Any]) -> None:
75
+ asyncio.run(consumer.process_command(payload))
76
+
77
+ @app.task(name="hexcore.process_handler", bind=True)
78
+ def process_handler(self: t.Any, handler_name: str, payload: dict[str, t.Any]) -> None:
79
+ asyncio.run(consumer.process_handler(handler_name, payload))
80
+
81
+ @app.task(name="hexcore.process_task", bind=True)
82
+ def process_task(self: t.Any, task_name: str, payload: dict[str, t.Any]) -> None:
83
+ asyncio.run(consumer.process_task(task_name, payload))
@@ -0,0 +1,61 @@
1
+ """
2
+ Adaptador para Procrastinate.
3
+ Permite encolar comandos y tareas usando Procrastinate, y proporciona
4
+ utilidades para auto-registrar las tareas asíncronas en el Worker.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import typing as t
9
+
10
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
11
+
12
+ if t.TYPE_CHECKING:
13
+ try:
14
+ import procrastinate
15
+ except ImportError:
16
+ procrastinate = t.Any
17
+
18
+ from hexcore.infrastructure.workers.consumer import CQRSConsumer
19
+
20
+
21
+ class ProcrastinateEnqueuer(ITaskEnqueuer):
22
+ """
23
+ Adaptador que utiliza Procrastinate (PostgreSQL) para encolar comandos y tareas.
24
+ Totalmente asíncrono y nativo.
25
+ """
26
+
27
+ def __init__(self, app: "procrastinate.App") -> None:
28
+ self.app = app
29
+
30
+ async def enqueue_command(self, command_name: str, payload: dict[str, t.Any], queue: str) -> None:
31
+ task = self.app.configure_task(name="hexcore.process_command", queue=queue)
32
+ await task.defer_async(payload=payload)
33
+
34
+ async def enqueue_event(self, event_name: str, payload: dict[str, t.Any], queue: str) -> None:
35
+ pass
36
+
37
+ async def enqueue_handler(self, handler_name: str, payload: dict[str, t.Any], queue: str) -> None:
38
+ task = self.app.configure_task(name="hexcore.process_handler", queue=queue)
39
+ await task.defer_async(handler_name=handler_name, payload=payload)
40
+
41
+ async def enqueue_task(self, task_name: str, payload: dict[str, t.Any], queue: str) -> None:
42
+ task = self.app.configure_task(name="hexcore.process_task", queue=queue)
43
+ await task.defer_async(task_name=task_name, payload=payload)
44
+
45
+
46
+ def register_hexcore_procrastinate_tasks(app: "procrastinate.App", consumer: "CQRSConsumer") -> None:
47
+ """
48
+ Auto-registra las tareas base de HexCore en una aplicación Procrastinate.
49
+ """
50
+
51
+ @app.task(name="hexcore.process_command")
52
+ async def process_command(payload: dict[str, t.Any]) -> None:
53
+ await consumer.process_command(payload)
54
+
55
+ @app.task(name="hexcore.process_handler")
56
+ async def process_handler(handler_name: str, payload: dict[str, t.Any]) -> None:
57
+ await consumer.process_handler(handler_name, payload)
58
+
59
+ @app.task(name="hexcore.process_task")
60
+ async def process_task(task_name: str, payload: dict[str, t.Any]) -> None:
61
+ await consumer.process_task(task_name, payload)