hexcore 2.2.0__tar.gz → 2.3.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 (100) hide show
  1. {hexcore-2.2.0/hexcore.egg-info → hexcore-2.3.0}/PKG-INFO +122 -1
  2. {hexcore-2.2.0 → hexcore-2.3.0}/README.md +121 -0
  3. hexcore-2.3.0/hexcore/application/cqrs/in_memory_buses.py +164 -0
  4. hexcore-2.3.0/hexcore/domain/cqrs/decorators.py +78 -0
  5. hexcore-2.3.0/hexcore/domain/cqrs/task_queues.py +64 -0
  6. hexcore-2.3.0/hexcore/infrastructure/workers/consumer.py +123 -0
  7. {hexcore-2.2.0 → hexcore-2.3.0/hexcore.egg-info}/PKG-INFO +122 -1
  8. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore.egg-info/SOURCES.txt +4 -0
  9. {hexcore-2.2.0 → hexcore-2.3.0}/pyproject.toml +1 -1
  10. hexcore-2.3.0/tests/test_smart_routing.py +218 -0
  11. hexcore-2.2.0/hexcore/application/cqrs/in_memory_buses.py +0 -99
  12. {hexcore-2.2.0 → hexcore-2.3.0}/LICENSE +0 -0
  13. {hexcore-2.2.0 → hexcore-2.3.0}/MANIFEST.in +0 -0
  14. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/__init__.py +0 -0
  15. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/__main__.py +0 -0
  16. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/__init__.py +0 -0
  17. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/cqrs/__init__.py +0 -0
  18. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/cqrs/adapters.py +0 -0
  19. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/cqrs/config.py +0 -0
  20. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/cqrs/factory.py +0 -0
  21. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/cqrs/pipeline.py +0 -0
  22. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/cqrs/registry.py +0 -0
  23. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/dtos/__init__.py +0 -0
  24. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/dtos/base.py +0 -0
  25. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/dtos/query.py +0 -0
  26. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/use_cases/__init__.py +0 -0
  27. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/use_cases/base.py +0 -0
  28. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/application/use_cases/query.py +0 -0
  29. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/config.py +0 -0
  30. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/__init__.py +0 -0
  31. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/auth/__init__.py +0 -0
  32. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/auth/permissions.py +0 -0
  33. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/auth/value_objects.py +0 -0
  34. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/base.py +0 -0
  35. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/cqrs/__init__.py +0 -0
  36. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/cqrs/buses.py +0 -0
  37. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/cqrs/commands.py +0 -0
  38. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  39. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/cqrs/handlers.py +0 -0
  40. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/cqrs/middleware.py +0 -0
  41. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/cqrs/queries.py +0 -0
  42. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/cqrs/serializer.py +0 -0
  43. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/events.py +0 -0
  44. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/exceptions.py +0 -0
  45. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/repositories.py +0 -0
  46. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/services.py +0 -0
  47. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/domain/uow.py +0 -0
  48. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/__init__.py +0 -0
  49. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/api/__init__.py +0 -0
  50. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/api/utils.py +0 -0
  51. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  52. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  53. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  54. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  55. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cli.py +0 -0
  56. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  57. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
  58. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  59. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  60. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  61. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/events/__init__.py +0 -0
  62. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  63. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  64. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  65. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/base.py +0 -0
  66. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  67. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
  68. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  69. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  70. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  71. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  72. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  73. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  74. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/repositories/utils.py +0 -0
  75. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/uow/__init__.py +0 -0
  76. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  77. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  78. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/workers/__init__.py +0 -0
  79. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
  80. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/py.typed +0 -0
  81. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore/types.py +0 -0
  82. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore.egg-info/dependency_links.txt +0 -0
  83. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore.egg-info/entry_points.txt +0 -0
  84. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore.egg-info/requires.txt +0 -0
  85. {hexcore-2.2.0 → hexcore-2.3.0}/hexcore.egg-info/top_level.txt +0 -0
  86. {hexcore-2.2.0 → hexcore-2.3.0}/scripts/__init__.py +0 -0
  87. {hexcore-2.2.0 → hexcore-2.3.0}/scripts/main.py +0 -0
  88. {hexcore-2.2.0 → hexcore-2.3.0}/setup.cfg +0 -0
  89. {hexcore-2.2.0 → hexcore-2.3.0}/tests/conftest.py +0 -0
  90. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_basic.py +0 -0
  91. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_beanie_query_utils.py +0 -0
  92. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_config_loading.py +0 -0
  93. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_cqrs.py +0 -0
  94. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_domain_service_query.py +0 -0
  95. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_infrastructure_query_path.py +0 -0
  96. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_query_field_validation.py +0 -0
  97. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_rabbitmq_bus.py +0 -0
  98. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_repositories_utils.py +0 -0
  99. {hexcore-2.2.0 → hexcore-2.3.0}/tests/test_uow_session_regression.py +0 -0
  100. {hexcore-2.2.0 → hexcore-2.3.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.3.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
@@ -456,6 +456,127 @@ En ambos casos, es tu **EventBus** (o un consumidor como Procrastinate) el encar
456
456
 
457
457
  ---
458
458
 
459
+ ### Integración con Task Queues (Smart Routing)
460
+
461
+ 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**.
462
+
463
+ 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.
464
+
465
+ #### 1. Decoradores de Background
466
+
467
+ HexCore ofrece 3 decoradores esenciales en `hexcore.domain.cqrs.decorators` para cubrir todos los casos de uso:
468
+
469
+ 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).
470
+ 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).
471
+ 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).
472
+
473
+ **Ejemplos de uso:**
474
+
475
+ ```python
476
+ from hexcore.domain.cqrs.decorators import background_command, background_handler, background_task
477
+ from hexcore.domain.cqrs.commands import Command
478
+
479
+ # 1. Comando de ejecución asíncrona obligatoria
480
+ @background_command(queue="high_priority")
481
+ class SendEmailCommand(Command):
482
+ user_id: str
483
+ template: str
484
+
485
+ # 2. Handler de evento asíncrono
486
+ @background_handler(queue="analytics")
487
+ async def send_analytics_on_user_created(event: UserCreatedEvent):
488
+ # Lógica costosa...
489
+ pass
490
+
491
+ # 3. Tarea genérica (Non-CQRS)
492
+ @background_task(queue="maintenance")
493
+ async def clean_old_records_task(days_retention: int):
494
+ # Limpieza de base de datos...
495
+ pass
496
+ ```
497
+
498
+ #### 2. Crear el Enqueuer (Adaptador)
499
+
500
+ Implementa la interfaz genérica `ITaskEnqueuer` usando la SDK de tu Task Queue favorito:
501
+
502
+ ```python
503
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
504
+
505
+ class ProcrastinateEnqueuer(ITaskEnqueuer):
506
+ async def enqueue_command(self, command_name: str, payload: dict, queue: str) -> None:
507
+ await process_cqrs_command.defer_async(payload=payload)
508
+
509
+ async def enqueue_handler(self, handler_name: str, payload: dict, queue: str) -> None:
510
+ await process_cqrs_handler.defer_async(handler_name=handler_name, payload=payload)
511
+
512
+ async def enqueue_event(self, event_name: str, payload: dict, queue: str) -> None:
513
+ pass # Usualmente no se usa directamente si utilizas @background_handler
514
+
515
+ async def enqueue_task(self, task_name: str, payload: dict, queue: str) -> None:
516
+ await process_generic_task.defer_async(task_name=task_name, payload=payload)
517
+ ```
518
+
519
+ #### 3. Configurar tus Buses (Enrutamiento Automático)
520
+
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).
522
+
523
+ ```python
524
+ from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus, InMemoryEventBus
525
+
526
+ enqueuer = ProcrastinateEnqueuer()
527
+ serializer = PydanticSerializer()
528
+
529
+ # El bus evalúa: ¿Tiene el comando @background_command? Si es así, usa el enqueuer.
530
+ command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
531
+
532
+ # El bus evalúa suscriptores: ¿Tienen @background_handler? Si es así, usa el enqueuer.
533
+ event_bus = InMemoryEventBus(enqueuer=enqueuer, serializer=serializer)
534
+ ```
535
+
536
+ #### 4. Ejecutar tareas genéricas
537
+
538
+ Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
539
+
540
+ ```python
541
+ # Así se encola una tarea genérica sin CQRS:
542
+ await enqueuer.enqueue_task(
543
+ task_name=clean_old_records_task.__cqrs_task_name__,
544
+ payload={"days_retention": 30},
545
+ queue=clean_old_records_task.__cqrs_queue__
546
+ )
547
+ ```
548
+
549
+ #### 5. Levantar el Worker (Consumidor Universal)
550
+
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.
552
+
553
+ ```python
554
+ from hexcore.infrastructure.workers.consumer import CQRSConsumer
555
+
556
+ consumer = CQRSConsumer(
557
+ command_bus=command_bus, # Tu CommandBus configurado
558
+ event_bus=event_bus,
559
+ serializer=serializer
560
+ )
561
+
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)
576
+ ```
577
+
578
+ ---
579
+
459
580
  ## Referencias
460
581
 
461
582
  - [CONTRIBUTING.md](./CONTRIBUTING.md): Pautas de colaboración.
@@ -430,6 +430,127 @@ 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
+ #### 3. Configurar tus Buses (Enrutamiento Automático)
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).
496
+
497
+ ```python
498
+ from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus, InMemoryEventBus
499
+
500
+ enqueuer = ProcrastinateEnqueuer()
501
+ serializer = PydanticSerializer()
502
+
503
+ # El bus evalúa: ¿Tiene el comando @background_command? Si es así, usa el enqueuer.
504
+ command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
505
+
506
+ # El bus evalúa suscriptores: ¿Tienen @background_handler? Si es así, usa el enqueuer.
507
+ event_bus = InMemoryEventBus(enqueuer=enqueuer, serializer=serializer)
508
+ ```
509
+
510
+ #### 4. Ejecutar tareas genéricas
511
+
512
+ Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
513
+
514
+ ```python
515
+ # Así se encola una tarea genérica sin CQRS:
516
+ await enqueuer.enqueue_task(
517
+ task_name=clean_old_records_task.__cqrs_task_name__,
518
+ payload={"days_retention": 30},
519
+ queue=clean_old_records_task.__cqrs_queue__
520
+ )
521
+ ```
522
+
523
+ #### 5. Levantar el Worker (Consumidor Universal)
524
+
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.
526
+
527
+ ```python
528
+ from hexcore.infrastructure.workers.consumer import CQRSConsumer
529
+
530
+ consumer = CQRSConsumer(
531
+ command_bus=command_bus, # Tu CommandBus configurado
532
+ event_bus=event_bus,
533
+ serializer=serializer
534
+ )
535
+
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)
550
+ ```
551
+
552
+ ---
553
+
433
554
  ## Referencias
434
555
 
435
556
  - [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
@@ -0,0 +1,123 @@
1
+ """
2
+ Consumidor Universal para colas de tareas (Celery, Procrastinate).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import importlib
7
+ import logging
8
+ import typing as t
9
+
10
+ from hexcore.domain.cqrs.buses import AbstractCommandBus, AbstractEventBus
11
+ from hexcore.domain.cqrs.serializer import ISerializer
12
+ from hexcore.domain.events import DomainEvent
13
+
14
+ if t.TYPE_CHECKING:
15
+ from hexcore.domain.cqrs.commands import Command
16
+
17
+ logger = logging.getLogger("hexcore.workers.consumer")
18
+
19
+
20
+ def _resolve_callable(qualname: str) -> t.Callable[..., t.Any]:
21
+ """Resuelve un import por su nombre calificado (ej. 'myapp.events.on_user_created')."""
22
+ try:
23
+ module_path, func_name = qualname.rsplit(".", 1)
24
+ module = importlib.import_module(module_path)
25
+ return getattr(module, func_name) # type: ignore
26
+ except (ValueError, ModuleNotFoundError, AttributeError) as exc:
27
+ raise RuntimeError(f"No se pudo resolver el handler/tarea '{qualname}': {exc}") from exc
28
+
29
+
30
+ class CQRSConsumer:
31
+ """
32
+ Ayudante universal para recibir mensajes serializados desde un Worker
33
+ y despacharlos a los handlers locales usando buses en memoria.
34
+
35
+ Uso (ej. Procrastinate):
36
+
37
+ consumer = CQRSConsumer(local_cmd_bus, local_evt_bus, serializer)
38
+
39
+ @app.task(name="process_cqrs_command")
40
+ async def process_cqrs_command(payload: dict):
41
+ await consumer.process_command(payload)
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ command_bus: AbstractCommandBus,
47
+ event_bus: AbstractEventBus,
48
+ serializer: ISerializer,
49
+ ) -> None:
50
+ """
51
+ Args:
52
+ command_bus: El bus de comandos *local* (usualmente InMemoryCommandBus)
53
+ que tiene registrados los handlers.
54
+ event_bus: El bus de eventos *local* (usualmente InMemoryEventBus).
55
+ serializer: El serializador usado (ej. PydanticSerializer).
56
+ """
57
+ self._command_bus = command_bus
58
+ self._event_bus = event_bus
59
+ self._serializer = serializer
60
+
61
+ async def process_command(self, payload: dict[str, t.Any]) -> None:
62
+ """
63
+ Deserializa un payload de comando y lo despacha al bus local.
64
+ """
65
+ try:
66
+ command = self._serializer.deserialize(payload)
67
+ cmd = t.cast("Command", command)
68
+
69
+ logger.info("[CQRSConsumer] Procesando comando en background: %s", type(cmd).__qualname__)
70
+ await self._command_bus.dispatch(cmd)
71
+
72
+ except Exception as exc:
73
+ logger.exception("[CQRSConsumer] Error procesando comando: %s", exc)
74
+ raise
75
+
76
+ async def process_event(self, payload: dict[str, t.Any]) -> None:
77
+ """
78
+ Deserializa un payload de evento y lo publica en el bus local
79
+ para que todos los handlers (suscriptores) locales lo consuman.
80
+ """
81
+ try:
82
+ event = self._serializer.deserialize(payload)
83
+ evt = t.cast(DomainEvent, event)
84
+
85
+ logger.info("[CQRSConsumer] Procesando evento en background: %s", evt.event_name)
86
+ await self._event_bus.publish(evt)
87
+
88
+ except Exception as exc:
89
+ logger.exception("[CQRSConsumer] Error procesando evento: %s", exc)
90
+ raise
91
+
92
+ async def process_handler(self, handler_name: str, payload: dict[str, t.Any]) -> None:
93
+ """
94
+ Deserializa el evento y ejecuta *específicamente* un handler decorado con `@background_handler`.
95
+ Esto es inyectado por el Smart Routing del EventBus.
96
+ """
97
+ try:
98
+ event = self._serializer.deserialize(payload)
99
+ evt = t.cast(DomainEvent, event)
100
+
101
+ logger.info("[CQRSConsumer] Ejecutando EventHandler individual en background: %s", handler_name)
102
+
103
+ handler_func = _resolve_callable(handler_name)
104
+ await handler_func(evt)
105
+
106
+ except Exception as exc:
107
+ logger.exception("[CQRSConsumer] Error procesando EventHandler %s: %s", handler_name, exc)
108
+ raise
109
+
110
+ async def process_task(self, task_name: str, payload: dict[str, t.Any]) -> None:
111
+ """
112
+ Ejecuta una tarea genérica de background decorada con `@background_task`.
113
+ """
114
+ try:
115
+ logger.info("[CQRSConsumer] Ejecutando Task genérica en background: %s", task_name)
116
+
117
+ task_func = _resolve_callable(task_name)
118
+ # El payload en tareas genéricas son los kwargs
119
+ await task_func(**payload)
120
+
121
+ except Exception as exc:
122
+ logger.exception("[CQRSConsumer] Error ejecutando Task %s: %s", task_name, exc)
123
+ raise