hexcore 2.2.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 (108) hide show
  1. {hexcore-2.2.0/hexcore.egg-info → hexcore-2.4.0}/PKG-INFO +129 -1
  2. {hexcore-2.2.0 → hexcore-2.4.0}/README.md +126 -0
  3. hexcore-2.4.0/hexcore/application/cqrs/in_memory_buses.py +164 -0
  4. hexcore-2.4.0/hexcore/domain/cqrs/decorators.py +78 -0
  5. hexcore-2.4.0/hexcore/domain/cqrs/task_queues.py +64 -0
  6. hexcore-2.4.0/hexcore/infrastructure/cqrs/postgres_bus.py +126 -0
  7. hexcore-2.4.0/hexcore/infrastructure/cqrs/redis_bus.py +143 -0
  8. hexcore-2.4.0/hexcore/infrastructure/task_queues/__init__.py +3 -0
  9. hexcore-2.4.0/hexcore/infrastructure/task_queues/celery_adapter.py +83 -0
  10. hexcore-2.4.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +61 -0
  11. hexcore-2.4.0/hexcore/infrastructure/workers/consumer.py +123 -0
  12. {hexcore-2.2.0 → hexcore-2.4.0/hexcore.egg-info}/PKG-INFO +129 -1
  13. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/SOURCES.txt +12 -0
  14. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/requires.txt +3 -0
  15. {hexcore-2.2.0 → hexcore-2.4.0}/pyproject.toml +2 -1
  16. hexcore-2.4.0/tests/test_postgres_bus.py +73 -0
  17. hexcore-2.4.0/tests/test_redis_bus.py +75 -0
  18. hexcore-2.4.0/tests/test_smart_routing.py +218 -0
  19. hexcore-2.4.0/tests/test_task_queues_adapters.py +82 -0
  20. hexcore-2.2.0/hexcore/application/cqrs/in_memory_buses.py +0 -99
  21. {hexcore-2.2.0 → hexcore-2.4.0}/LICENSE +0 -0
  22. {hexcore-2.2.0 → hexcore-2.4.0}/MANIFEST.in +0 -0
  23. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/__init__.py +0 -0
  24. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/__main__.py +0 -0
  25. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/__init__.py +0 -0
  26. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/__init__.py +0 -0
  27. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/adapters.py +0 -0
  28. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/config.py +0 -0
  29. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/factory.py +0 -0
  30. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/pipeline.py +0 -0
  31. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/registry.py +0 -0
  32. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/dtos/__init__.py +0 -0
  33. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/dtos/base.py +0 -0
  34. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/dtos/query.py +0 -0
  35. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/use_cases/__init__.py +0 -0
  36. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/use_cases/base.py +0 -0
  37. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/use_cases/query.py +0 -0
  38. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/config.py +0 -0
  39. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/__init__.py +0 -0
  40. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/auth/__init__.py +0 -0
  41. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/auth/permissions.py +0 -0
  42. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/auth/value_objects.py +0 -0
  43. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/base.py +0 -0
  44. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/__init__.py +0 -0
  45. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/buses.py +0 -0
  46. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/commands.py +0 -0
  47. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  48. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/handlers.py +0 -0
  49. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/middleware.py +0 -0
  50. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/queries.py +0 -0
  51. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/serializer.py +0 -0
  52. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/events.py +0 -0
  53. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/exceptions.py +0 -0
  54. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/repositories.py +0 -0
  55. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/services.py +0 -0
  56. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/uow.py +0 -0
  57. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/__init__.py +0 -0
  58. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/api/__init__.py +0 -0
  59. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/api/utils.py +0 -0
  60. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  61. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  62. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  63. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  64. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cli.py +0 -0
  65. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  66. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
  67. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  68. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  69. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  70. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/events/__init__.py +0 -0
  71. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  72. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  73. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  74. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/base.py +0 -0
  75. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  76. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
  77. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  78. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  79. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  80. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  81. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  82. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  83. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/utils.py +0 -0
  84. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/__init__.py +0 -0
  85. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  86. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  87. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/__init__.py +0 -0
  88. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
  89. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/py.typed +0 -0
  90. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/types.py +0 -0
  91. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/dependency_links.txt +0 -0
  92. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/entry_points.txt +0 -0
  93. {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/top_level.txt +0 -0
  94. {hexcore-2.2.0 → hexcore-2.4.0}/scripts/__init__.py +0 -0
  95. {hexcore-2.2.0 → hexcore-2.4.0}/scripts/main.py +0 -0
  96. {hexcore-2.2.0 → hexcore-2.4.0}/setup.cfg +0 -0
  97. {hexcore-2.2.0 → hexcore-2.4.0}/tests/conftest.py +0 -0
  98. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_basic.py +0 -0
  99. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_beanie_query_utils.py +0 -0
  100. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_config_loading.py +0 -0
  101. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_cqrs.py +0 -0
  102. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_domain_service_query.py +0 -0
  103. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_infrastructure_query_path.py +0 -0
  104. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_query_field_validation.py +0 -0
  105. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_rabbitmq_bus.py +0 -0
  106. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_repositories_utils.py +0 -0
  107. {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_uow_session_regression.py +0 -0
  108. {hexcore-2.2.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.2.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)
@@ -456,6 +458,132 @@ En ambos casos, es tu **EventBus** (o un consumidor como Procrastinate) el encar
456
458
 
457
459
  ---
458
460
 
461
+ ### Integración con Task Queues (Smart Routing)
462
+
463
+ HexCore v2 hace que la delegación de tareas a **Celery**, **Procrastinate** o **ARQ** sea increíblemente sencilla y mágica a través del patrón de **Smart Routing**.
464
+
465
+ Ya no necesitas instanciar buses separados para código síncrono y asíncrono. HexCore enruta automáticamente tus comandos y eventos hacia las colas de background usando simples decoradores.
466
+
467
+ #### 1. Decoradores de Background
468
+
469
+ HexCore ofrece 3 decoradores esenciales en `hexcore.domain.cqrs.decorators` para cubrir todos los casos de uso:
470
+
471
+ 1. **`@background_command(queue="...")`**: Aplícalo sobre una clase `Command`. Todo el comando y su handler se ejecutarán asíncronamente en el Worker. Ideal para operaciones pesadas iniciadas por el usuario (ej. Generar un reporte PDF masivo).
472
+ 2. **`@background_handler(queue="...")`**: Aplícalo sobre una función que maneje un evento (`DomainEvent`). Permite que un solo evento dispare algunas acciones síncronas rápidas y otras asíncronas lentas (ej. Enviar emails).
473
+ 3. **`@background_task(queue="...")`**: Aplícalo sobre funciones o utilidades genéricas que no pertenecen al modelo estricto de CQRS (ej. Limpiar base de datos, tareas tipo CRON).
474
+
475
+ **Ejemplos de uso:**
476
+
477
+ ```python
478
+ from hexcore.domain.cqrs.decorators import background_command, background_handler, background_task
479
+ from hexcore.domain.cqrs.commands import Command
480
+
481
+ # 1. Comando de ejecución asíncrona obligatoria
482
+ @background_command(queue="high_priority")
483
+ class SendEmailCommand(Command):
484
+ user_id: str
485
+ template: str
486
+
487
+ # 2. Handler de evento asíncrono
488
+ @background_handler(queue="analytics")
489
+ async def send_analytics_on_user_created(event: UserCreatedEvent):
490
+ # Lógica costosa...
491
+ pass
492
+
493
+ # 3. Tarea genérica (Non-CQRS)
494
+ @background_task(queue="maintenance")
495
+ async def clean_old_records_task(days_retention: int):
496
+ # Limpieza de base de datos...
497
+ pass
498
+ ```
499
+
500
+ #### 2. Crear el Enqueuer (Adaptador)
501
+
502
+ Implementa la interfaz genérica `ITaskEnqueuer` usando la SDK de tu Task Queue favorito:
503
+
504
+ ```python
505
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
506
+
507
+ class ProcrastinateEnqueuer(ITaskEnqueuer):
508
+ async def enqueue_command(self, command_name: str, payload: dict, queue: str) -> None:
509
+ await process_cqrs_command.defer_async(payload=payload)
510
+
511
+ async def enqueue_handler(self, handler_name: str, payload: dict, queue: str) -> None:
512
+ await process_cqrs_handler.defer_async(handler_name=handler_name, payload=payload)
513
+
514
+ async def enqueue_event(self, event_name: str, payload: dict, queue: str) -> None:
515
+ pass # Usualmente no se usa directamente si utilizas @background_handler
516
+
517
+ async def enqueue_task(self, task_name: str, payload: dict, queue: str) -> None:
518
+ await process_generic_task.defer_async(task_name=task_name, payload=payload)
519
+ ```
520
+
521
+ #### 2. Configurar tus Buses con un Adaptador Oficial
522
+
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.
524
+
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`:
526
+
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)
537
+ serializer = PydanticSerializer()
538
+
539
+ command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
540
+
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
+ )
550
+ ```
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
+
554
+ #### 4. Ejecutar tareas genéricas
555
+
556
+ Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
557
+
558
+ ```python
559
+ # Así se encola una tarea genérica sin CQRS:
560
+ await enqueuer.enqueue_task(
561
+ task_name=clean_old_records_task.__cqrs_task_name__,
562
+ payload={"days_retention": 30},
563
+ queue=clean_old_records_task.__cqrs_queue__
564
+ )
565
+ ```
566
+
567
+ #### 5. Levantar el Worker (Consumidor Universal)
568
+
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:
570
+
571
+ ```python
572
+ from hexcore.infrastructure.workers.consumer import CQRSConsumer
573
+ from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
574
+
575
+ consumer = CQRSConsumer(
576
+ command_bus=command_bus, # Tu CommandBus configurado
577
+ event_bus=event_bus,
578
+ serializer=serializer
579
+ )
580
+
581
+ # ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
582
+ register_hexcore_celery_tasks(app, consumer)
583
+ ```
584
+
585
+ ---
586
+
459
587
  ## Referencias
460
588
 
461
589
  - [CONTRIBUTING.md](./CONTRIBUTING.md): Pautas de colaboración.
@@ -430,6 +430,132 @@ En ambos casos, es tu **EventBus** (o un consumidor como Procrastinate) el encar
430
430
 
431
431
  ---
432
432
 
433
+ ### Integración con Task Queues (Smart Routing)
434
+
435
+ HexCore v2 hace que la delegación de tareas a **Celery**, **Procrastinate** o **ARQ** sea increíblemente sencilla y mágica a través del patrón de **Smart Routing**.
436
+
437
+ Ya no necesitas instanciar buses separados para código síncrono y asíncrono. HexCore enruta automáticamente tus comandos y eventos hacia las colas de background usando simples decoradores.
438
+
439
+ #### 1. Decoradores de Background
440
+
441
+ HexCore ofrece 3 decoradores esenciales en `hexcore.domain.cqrs.decorators` para cubrir todos los casos de uso:
442
+
443
+ 1. **`@background_command(queue="...")`**: Aplícalo sobre una clase `Command`. Todo el comando y su handler se ejecutarán asíncronamente en el Worker. Ideal para operaciones pesadas iniciadas por el usuario (ej. Generar un reporte PDF masivo).
444
+ 2. **`@background_handler(queue="...")`**: Aplícalo sobre una función que maneje un evento (`DomainEvent`). Permite que un solo evento dispare algunas acciones síncronas rápidas y otras asíncronas lentas (ej. Enviar emails).
445
+ 3. **`@background_task(queue="...")`**: Aplícalo sobre funciones o utilidades genéricas que no pertenecen al modelo estricto de CQRS (ej. Limpiar base de datos, tareas tipo CRON).
446
+
447
+ **Ejemplos de uso:**
448
+
449
+ ```python
450
+ from hexcore.domain.cqrs.decorators import background_command, background_handler, background_task
451
+ from hexcore.domain.cqrs.commands import Command
452
+
453
+ # 1. Comando de ejecución asíncrona obligatoria
454
+ @background_command(queue="high_priority")
455
+ class SendEmailCommand(Command):
456
+ user_id: str
457
+ template: str
458
+
459
+ # 2. Handler de evento asíncrono
460
+ @background_handler(queue="analytics")
461
+ async def send_analytics_on_user_created(event: UserCreatedEvent):
462
+ # Lógica costosa...
463
+ pass
464
+
465
+ # 3. Tarea genérica (Non-CQRS)
466
+ @background_task(queue="maintenance")
467
+ async def clean_old_records_task(days_retention: int):
468
+ # Limpieza de base de datos...
469
+ pass
470
+ ```
471
+
472
+ #### 2. Crear el Enqueuer (Adaptador)
473
+
474
+ Implementa la interfaz genérica `ITaskEnqueuer` usando la SDK de tu Task Queue favorito:
475
+
476
+ ```python
477
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
478
+
479
+ class ProcrastinateEnqueuer(ITaskEnqueuer):
480
+ async def enqueue_command(self, command_name: str, payload: dict, queue: str) -> None:
481
+ await process_cqrs_command.defer_async(payload=payload)
482
+
483
+ async def enqueue_handler(self, handler_name: str, payload: dict, queue: str) -> None:
484
+ await process_cqrs_handler.defer_async(handler_name=handler_name, payload=payload)
485
+
486
+ async def enqueue_event(self, event_name: str, payload: dict, queue: str) -> None:
487
+ pass # Usualmente no se usa directamente si utilizas @background_handler
488
+
489
+ async def enqueue_task(self, task_name: str, payload: dict, queue: str) -> None:
490
+ await process_generic_task.defer_async(task_name=task_name, payload=payload)
491
+ ```
492
+
493
+ #### 2. Configurar tus Buses con un Adaptador Oficial
494
+
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
+
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`:
498
+
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)
509
+ serializer = PydanticSerializer()
510
+
511
+ command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
512
+
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
+ )
522
+ ```
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
+
526
+ #### 4. Ejecutar tareas genéricas
527
+
528
+ Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
529
+
530
+ ```python
531
+ # Así se encola una tarea genérica sin CQRS:
532
+ await enqueuer.enqueue_task(
533
+ task_name=clean_old_records_task.__cqrs_task_name__,
534
+ payload={"days_retention": 30},
535
+ queue=clean_old_records_task.__cqrs_queue__
536
+ )
537
+ ```
538
+
539
+ #### 5. Levantar el Worker (Consumidor Universal)
540
+
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:
542
+
543
+ ```python
544
+ from hexcore.infrastructure.workers.consumer import CQRSConsumer
545
+ from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
546
+
547
+ consumer = CQRSConsumer(
548
+ command_bus=command_bus, # Tu CommandBus configurado
549
+ event_bus=event_bus,
550
+ serializer=serializer
551
+ )
552
+
553
+ # ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
554
+ register_hexcore_celery_tasks(app, consumer)
555
+ ```
556
+
557
+ ---
558
+
433
559
  ## Referencias
434
560
 
435
561
  - [CONTRIBUTING.md](./CONTRIBUTING.md): Pautas de colaboración.
@@ -0,0 +1,164 @@
1
+ """
2
+ Implementaciones In-Memory de los buses CQRS con soporte para Enrutamiento Inteligente (Smart Routing).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import typing as t
7
+ import logging
8
+
9
+ from hexcore.domain.cqrs.buses import ICommandBus, IQueryBus, IEventBus
10
+ from hexcore.domain.cqrs.commands import Command
11
+ from hexcore.domain.cqrs.queries import Query
12
+ from hexcore.domain.events import DomainEvent
13
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
14
+ from hexcore.domain.cqrs.serializer import ISerializer
15
+
16
+ from .registry import HandlerRegistry
17
+ from .pipeline import MiddlewarePipeline
18
+
19
+ logger = logging.getLogger("hexcore.cqrs.buses")
20
+
21
+
22
+ class InMemoryCommandBus(ICommandBus):
23
+ """
24
+ Bus de commands síncrono en memoria con Smart Routing.
25
+ Resuelve el handler desde el registry, ejecuta el pipeline de middlewares
26
+ y retorna el resultado.
27
+
28
+ Si el comando está decorado con `@background_command` y se provee un `enqueuer`,
29
+ el comando es automáticamente encolado para su ejecución en segundo plano
30
+ sin bloquear el proceso actual (retornando None).
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ registry: HandlerRegistry,
36
+ pipeline: MiddlewarePipeline | None = None,
37
+ enqueuer: ITaskEnqueuer | None = None,
38
+ serializer: ISerializer | None = None,
39
+ ) -> None:
40
+ self._registry = registry
41
+ self._pipeline = pipeline or MiddlewarePipeline()
42
+ self._enqueuer = enqueuer
43
+ self._serializer = serializer
44
+
45
+ async def dispatch(self, command: Command) -> t.Any:
46
+ cmd_type = type(command)
47
+
48
+ # 1. Smart Routing: ¿Debe irse a background?
49
+ is_background = getattr(cmd_type, "__cqrs_background__", False)
50
+
51
+ if is_background:
52
+ if not self._enqueuer or not self._serializer:
53
+ raise RuntimeError(
54
+ f"El comando '{cmd_type.__name__}' requiere ejecución en background, "
55
+ "pero el InMemoryCommandBus no tiene configurado un 'enqueuer' o 'serializer'."
56
+ )
57
+
58
+ async def background_dispatcher(cmd: Command) -> None:
59
+ queue_name = getattr(cmd_type, "__cqrs_queue__", "default")
60
+ payload = self._serializer.serialize(cmd) # type: ignore
61
+ await self._enqueuer.enqueue_command(cmd_type.__name__, payload, queue=queue_name) # type: ignore
62
+ logger.debug("[SmartRouting] Comando %s enrutado a background (queue=%s)", cmd_type.__name__, queue_name)
63
+
64
+ await self._pipeline.execute(command, background_dispatcher)
65
+ return None
66
+
67
+ # 2. Ejecución Local Estándar
68
+ handler = self._registry.resolve_command_handler(cmd_type)
69
+
70
+ async def final_handler(cmd: t.Any) -> t.Any:
71
+ return await handler.handle(cmd)
72
+
73
+ return await self._pipeline.execute(command, final_handler)
74
+
75
+
76
+ class InMemoryQueryBus(IQueryBus):
77
+ """
78
+ Bus de queries síncrono en memoria.
79
+ Las queries NUNCA pasan por buses asíncronos
80
+ (principio CQRS: las lecturas son siempre síncronas).
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ registry: HandlerRegistry,
86
+ pipeline: MiddlewarePipeline | None = None,
87
+ ) -> None:
88
+ self._registry = registry
89
+ self._pipeline = pipeline or MiddlewarePipeline()
90
+
91
+ async def ask(self, query: Query[t.Any]) -> t.Any:
92
+ handler = self._registry.resolve_query_handler(type(query))
93
+
94
+ async def final_handler(q: t.Any) -> t.Any:
95
+ return await handler.handle(q)
96
+
97
+ return await self._pipeline.execute(query, final_handler)
98
+
99
+
100
+ class InMemoryEventBus(IEventBus):
101
+ """
102
+ Bus de eventos en memoria con Smart Routing para Handlers Asíncronos.
103
+
104
+ Permite publicar eventos a todos los suscriptores. Si un suscriptor
105
+ fue decorado con `@background_handler`, se enviará un mensaje al Task Queue
106
+ para que *solo* ese handler se ejecute en background para este evento.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ pipeline: MiddlewarePipeline | None = None,
112
+ enqueuer: ITaskEnqueuer | None = None,
113
+ serializer: ISerializer | None = None,
114
+ ) -> None:
115
+ self._handlers: dict[
116
+ type[DomainEvent],
117
+ list[t.Callable[[DomainEvent], t.Awaitable[None]]],
118
+ ] = {}
119
+ self._pipeline = pipeline or MiddlewarePipeline()
120
+ self._enqueuer = enqueuer
121
+ self._serializer = serializer
122
+
123
+ def subscribe(
124
+ self,
125
+ event_type: type[DomainEvent],
126
+ handler: t.Callable[[DomainEvent], t.Awaitable[None]],
127
+ ) -> None:
128
+ self._handlers.setdefault(event_type, []).append(handler)
129
+
130
+ async def publish(self, event: DomainEvent) -> None:
131
+ handlers = self._handlers.get(type(event), [])
132
+
133
+ for event_handler in handlers:
134
+ is_background = getattr(event_handler, "__cqrs_background_handler__", False)
135
+
136
+ if is_background:
137
+ # Enrutamiento hacia background
138
+ if not self._enqueuer or not self._serializer:
139
+ raise RuntimeError(
140
+ f"El handler de eventos '{event_handler.__name__}' requiere ejecución en background, "
141
+ "pero el InMemoryEventBus no tiene configurado un 'enqueuer' o 'serializer'."
142
+ )
143
+
144
+ async def background_dispatcher(
145
+ evt: t.Any,
146
+ _h: t.Callable[..., t.Awaitable[None]] = event_handler,
147
+ ) -> None:
148
+ handler_name = getattr(_h, "__cqrs_handler_name__")
149
+ queue_name = getattr(_h, "__cqrs_queue__", "default")
150
+ payload = self._serializer.serialize(evt) # type: ignore
151
+ await self._enqueuer.enqueue_handler(handler_name, payload, queue=queue_name) # type: ignore
152
+ logger.debug("[SmartRouting] EventHandler %s enrutado a background (queue=%s)", handler_name, queue_name)
153
+
154
+ await self._pipeline.execute(event, background_dispatcher)
155
+
156
+ else:
157
+ # Ejecución local síncrona
158
+ async def final_handler(
159
+ evt: t.Any,
160
+ _h: t.Callable[..., t.Awaitable[None]] = event_handler,
161
+ ) -> None:
162
+ await _h(evt)
163
+
164
+ await self._pipeline.execute(event, final_handler)
@@ -0,0 +1,78 @@
1
+ """
2
+ Decoradores para habilitar el enrutamiento inteligente (Smart Routing) de tareas, comandos
3
+ y manejadores de eventos hacia Task Queues (Celery, Procrastinate, etc.) de forma transparente.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import typing as t
8
+
9
+
10
+ def background_command(queue: str = "default") -> t.Callable[[t.Type[t.Any]], t.Type[t.Any]]:
11
+ """
12
+ Decora una clase `Command` para indicar que su ejecución debe enviarse siempre
13
+ a un procesador en segundo plano (Task Queue).
14
+
15
+ Cuando el CommandBus detecta este decorador, no ejecutará el comando localmente,
16
+ sino que llamará a `ITaskEnqueuer.enqueue_command()`.
17
+
18
+ Ejemplo:
19
+ @background_command(queue="emails")
20
+ class SendEmailCommand(Command):
21
+ ...
22
+ """
23
+ def wrapper(cls: t.Type[t.Any]) -> t.Type[t.Any]:
24
+ cls.__cqrs_background__ = True
25
+ cls.__cqrs_queue__ = queue
26
+ return cls
27
+
28
+ return wrapper
29
+
30
+
31
+ def background_handler(queue: str = "default") -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]:
32
+ """
33
+ Decora un manejador de eventos (Event Handler) para indicar que su ejecución
34
+ debe ocurrir de forma asíncrona en un Worker de background.
35
+
36
+ Cuando el EventBus detecta este decorador en un handler suscrito, en lugar de
37
+ ejecutarlo directamente, delegará la tarea a `ITaskEnqueuer.enqueue_handler()`.
38
+
39
+ Ejemplo:
40
+ @background_handler(queue="analytics")
41
+ async def on_user_created(event: UserCreatedEvent):
42
+ ...
43
+ """
44
+ def wrapper(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
45
+ func.__cqrs_background_handler__ = True
46
+ func.__cqrs_queue__ = queue
47
+
48
+ # Guardamos el nombre calificado del handler para poder resolverlo luego en el Worker
49
+ if not hasattr(func, "__qualname__"):
50
+ func.__cqrs_handler_name__ = func.__name__
51
+ else:
52
+ func.__cqrs_handler_name__ = f"{func.__module__}.{func.__qualname__}"
53
+
54
+ return func
55
+
56
+ return wrapper
57
+
58
+
59
+ def background_task(queue: str = "default") -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]:
60
+ """
61
+ Decora una función genérica para convertirla en una Tarea de Background
62
+ (independiente de CQRS).
63
+
64
+ La función original queda marcada, y puede ser registrada en el
65
+ `TaskManager` de HexCore o ejecutada directamente usando el `ITaskEnqueuer`.
66
+ """
67
+ def wrapper(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
68
+ func.__cqrs_background_task__ = True
69
+ func.__cqrs_queue__ = queue
70
+
71
+ if not hasattr(func, "__qualname__"):
72
+ func.__cqrs_task_name__ = func.__name__
73
+ else:
74
+ func.__cqrs_task_name__ = f"{func.__module__}.{func.__qualname__}"
75
+
76
+ return func
77
+
78
+ return wrapper
@@ -0,0 +1,64 @@
1
+ """
2
+ Interfaces fundamentales para delegar mensajes a Task Queues (Celery, Procrastinate, RQ, ARQ).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import abc
7
+ import typing as t
8
+
9
+ if t.TYPE_CHECKING:
10
+ pass
11
+
12
+
13
+ class ITaskEnqueuer(abc.ABC):
14
+ """
15
+ Puerto (Puerto de Salida) genérico para encolar tareas asíncronas
16
+ o en paralelo hacia un backend de colas de mensajes (Task Queue).
17
+
18
+ Un adaptador concreto (ej. `CeleryTaskEnqueuer`) usará la SDK
19
+ nativa del worker para empujar el `payload`.
20
+ """
21
+
22
+ @abc.abstractmethod
23
+ async def enqueue_command(self, command_name: str, payload: dict[str, t.Any], queue: str) -> None:
24
+ """
25
+ Encola un comando de dominio serializado para ser ejecutado en background.
26
+
27
+ Args:
28
+ command_name: Nombre calificado del comando (e.g. 'CreateUserCommand').
29
+ payload: Payload pre-serializado.
30
+ queue: Nombre de la cola de destino.
31
+ """
32
+ raise NotImplementedError
33
+
34
+ @abc.abstractmethod
35
+ async def enqueue_event(self, event_name: str, payload: dict[str, t.Any], queue: str) -> None:
36
+ """
37
+ Encola un evento de dominio serializado para ser distribuido genéricamente.
38
+ """
39
+ raise NotImplementedError
40
+
41
+ @abc.abstractmethod
42
+ async def enqueue_handler(self, handler_name: str, payload: dict[str, t.Any], queue: str) -> None:
43
+ """
44
+ Encola la ejecución de un *Event Handler Específico* para un evento.
45
+ Esto permite que un Evento dispare handlers asíncronos individuales.
46
+
47
+ Args:
48
+ handler_name: Nombre calificado del handler (func.__cqrs_handler_name__).
49
+ payload: Payload pre-serializado del evento.
50
+ queue: Nombre de la cola.
51
+ """
52
+ raise NotImplementedError
53
+
54
+ @abc.abstractmethod
55
+ async def enqueue_task(self, task_name: str, payload: dict[str, t.Any], queue: str) -> None:
56
+ """
57
+ Encola una tarea genérica de background (independiente de CQRS).
58
+
59
+ Args:
60
+ task_name: Nombre calificado de la tarea (func.__cqrs_task_name__).
61
+ payload: Argumentos serializados para la tarea.
62
+ queue: Nombre de la cola.
63
+ """
64
+ raise NotImplementedError