hexcore 2.5.0__tar.gz → 3.0.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 (136) hide show
  1. {hexcore-2.5.0/hexcore.egg-info → hexcore-3.0.0}/PKG-INFO +14 -4
  2. {hexcore-2.5.0 → hexcore-3.0.0}/README.md +13 -3
  3. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/cqrs/config.py +6 -7
  4. hexcore-3.0.0/hexcore/application/cqrs/factory.py +215 -0
  5. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/cqrs/in_memory_buses.py +24 -7
  6. hexcore-3.0.0/hexcore/application/cqrs/registry.py +185 -0
  7. hexcore-3.0.0/hexcore/application/cqrs/scheduler.py +242 -0
  8. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/__init__.py +9 -0
  9. hexcore-3.0.0/hexcore/domain/cqrs/context.py +61 -0
  10. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/decorators.py +27 -17
  11. hexcore-3.0.0/hexcore/domain/cqrs/resolution.py +87 -0
  12. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/api/utils.py +21 -0
  13. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/middlewares.py +29 -20
  14. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/postgres_bus.py +11 -3
  15. hexcore-3.0.0/hexcore/infrastructure/cqrs/postgres_lock.py +190 -0
  16. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +6 -6
  17. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/redis_bus.py +12 -4
  18. hexcore-3.0.0/hexcore/infrastructure/cqrs/redis_lock.py +110 -0
  19. hexcore-3.0.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +208 -0
  20. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/task_queues/celery_adapter.py +51 -6
  21. hexcore-3.0.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +120 -0
  22. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/uow/__init__.py +20 -0
  23. hexcore-3.0.0/hexcore/infrastructure/uow/scopes.py +93 -0
  24. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/workers/consumer.py +67 -22
  25. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +10 -6
  26. {hexcore-2.5.0 → hexcore-3.0.0/hexcore.egg-info}/PKG-INFO +14 -4
  27. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore.egg-info/SOURCES.txt +13 -1
  28. {hexcore-2.5.0 → hexcore-3.0.0}/pyproject.toml +1 -1
  29. hexcore-3.0.0/tests/test_consumer_optional_event_bus.py +68 -0
  30. hexcore-3.0.0/tests/test_cqrs_factory.py +141 -0
  31. hexcore-3.0.0/tests/test_cqrs_middlewares.py +61 -0
  32. hexcore-3.0.0/tests/test_dotted_resolution.py +138 -0
  33. hexcore-3.0.0/tests/test_handler_registry.py +199 -0
  34. hexcore-3.0.0/tests/test_lock_error_policy.py +132 -0
  35. hexcore-3.0.0/tests/test_postgres_lock.py +152 -0
  36. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_scheduler.py +2 -0
  37. hexcore-3.0.0/tests/test_scheduler_catchup.py +361 -0
  38. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_smart_routing.py +33 -23
  39. hexcore-3.0.0/tests/test_sql_session_layer.py +338 -0
  40. hexcore-3.0.0/tests/test_task_queues_adapters.py +162 -0
  41. hexcore-3.0.0/tests/test_worker_bus_integration.py +271 -0
  42. hexcore-2.5.0/hexcore/application/cqrs/factory.py +0 -131
  43. hexcore-2.5.0/hexcore/application/cqrs/registry.py +0 -115
  44. hexcore-2.5.0/hexcore/application/cqrs/scheduler.py +0 -99
  45. hexcore-2.5.0/hexcore/infrastructure/cqrs/postgres_lock.py +0 -95
  46. hexcore-2.5.0/hexcore/infrastructure/cqrs/redis_lock.py +0 -62
  47. hexcore-2.5.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -66
  48. hexcore-2.5.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -61
  49. hexcore-2.5.0/tests/test_postgres_lock.py +0 -77
  50. hexcore-2.5.0/tests/test_task_queues_adapters.py +0 -82
  51. {hexcore-2.5.0 → hexcore-3.0.0}/LICENSE +0 -0
  52. {hexcore-2.5.0 → hexcore-3.0.0}/MANIFEST.in +0 -0
  53. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/__init__.py +0 -0
  54. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/__main__.py +0 -0
  55. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/__init__.py +0 -0
  56. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/cqrs/__init__.py +0 -0
  57. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/cqrs/adapters.py +0 -0
  58. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/cqrs/pipeline.py +0 -0
  59. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/dtos/__init__.py +0 -0
  60. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/dtos/base.py +0 -0
  61. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/dtos/query.py +0 -0
  62. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/use_cases/__init__.py +0 -0
  63. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/use_cases/base.py +0 -0
  64. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/application/use_cases/query.py +0 -0
  65. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/config.py +0 -0
  66. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/__init__.py +0 -0
  67. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/auth/__init__.py +0 -0
  68. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/auth/permissions.py +0 -0
  69. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/auth/value_objects.py +0 -0
  70. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/base.py +0 -0
  71. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/buses.py +0 -0
  72. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/commands.py +0 -0
  73. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/cron.py +0 -0
  74. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  75. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/handlers.py +0 -0
  76. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/middleware.py +0 -0
  77. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/queries.py +0 -0
  78. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/serializer.py +0 -0
  79. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/cqrs/task_queues.py +0 -0
  80. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/events.py +0 -0
  81. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/exceptions.py +0 -0
  82. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/repositories.py +0 -0
  83. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/services.py +0 -0
  84. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/domain/uow.py +0 -0
  85. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/__init__.py +0 -0
  86. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/api/__init__.py +0 -0
  87. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  88. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  89. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  90. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  91. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cli.py +0 -0
  92. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  93. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  94. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  95. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/events/__init__.py +0 -0
  96. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  97. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  98. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  99. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/base.py +0 -0
  100. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  101. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
  102. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  103. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  104. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  105. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  106. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  107. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/utils.py +0 -0
  108. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
  109. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  110. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  111. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/infrastructure/workers/__init__.py +0 -0
  112. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/py.typed +0 -0
  113. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore/types.py +0 -0
  114. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore.egg-info/dependency_links.txt +0 -0
  115. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore.egg-info/entry_points.txt +0 -0
  116. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore.egg-info/requires.txt +0 -0
  117. {hexcore-2.5.0 → hexcore-3.0.0}/hexcore.egg-info/top_level.txt +0 -0
  118. {hexcore-2.5.0 → hexcore-3.0.0}/scripts/__init__.py +0 -0
  119. {hexcore-2.5.0 → hexcore-3.0.0}/scripts/main.py +0 -0
  120. {hexcore-2.5.0 → hexcore-3.0.0}/setup.cfg +0 -0
  121. {hexcore-2.5.0 → hexcore-3.0.0}/tests/conftest.py +0 -0
  122. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_basic.py +0 -0
  123. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_beanie_query_utils.py +0 -0
  124. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_config_loading.py +0 -0
  125. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_cqrs.py +0 -0
  126. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_domain_service_query.py +0 -0
  127. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_infrastructure_query_path.py +0 -0
  128. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_optional_dependencies.py +0 -0
  129. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_postgres_bus.py +0 -0
  130. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_query_field_validation.py +0 -0
  131. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_rabbitmq_bus.py +0 -0
  132. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_redis_bus.py +0 -0
  133. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_redis_lock.py +0 -0
  134. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_repositories_utils.py +0 -0
  135. {hexcore-2.5.0 → hexcore-3.0.0}/tests/test_uow_session_regression.py +0 -0
  136. {hexcore-2.5.0 → hexcore-3.0.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.5.0
3
+ Version: 3.0.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
@@ -297,7 +297,7 @@ HexCore v2 integra de forma nativa soporte para el patrón **CQRS (Command Query
297
297
 
298
298
  El sistema se basa en 3 buses principales, configurables e independientes:
299
299
 
300
- 1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema y se ejecutan (por defecto) dentro de una transacción de base de datos (Unit of Work).
300
+ 1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema. La transacción la gestiona el handler (el patrón que enseñan los ejemplos de use case); si preferís que la gestione el bus, añadí `TransactionMiddleware` explícitamente con su `uow_factory`.
301
301
  2. **`AbstractQueryBus`**: Despacha intenciones de lectura (`Query`) a un único `AbstractQueryHandler`. Retornan un resultado sin mutar el estado.
302
302
  3. **`EventBus`**: Distribuye eventos de dominio (`DomainEvent`) a múltiples suscriptores asíncronamente (vía `subscribe`/`publish`).
303
303
 
@@ -310,8 +310,9 @@ from hexcore.application.cqrs.config import CQRSConfig, BusConfig
310
310
  config = ServerConfig(
311
311
  cqrs=CQRSConfig(
312
312
  command_bus=BusConfig(
313
- # Por defecto incluye TransactionMiddleware
314
- middlewares=["hexcore.infrastructure.cqrs.middlewares.TransactionMiddleware"]
313
+ # Sin middlewares por defecto. Los que no necesitan configuración se
314
+ # pueden declarar por dotted path:
315
+ middlewares=["hexcore.infrastructure.cqrs.middlewares.LoggingMiddleware"]
315
316
  ),
316
317
  # Puedes sustituir el backend en memoria por uno distribuido (Ej: Celery, Procrastinate)
317
318
  # backend="mi_app.infrastructure.ProcrastinateCommandBus"
@@ -319,6 +320,15 @@ config = ServerConfig(
319
320
  )
320
321
  ```
321
322
 
323
+ > **`TransactionMiddleware` no es el default.** Comitea después del handler, así que
324
+ > con un handler que ya gestiona su propia transacción comitearías dos veces. Y
325
+ > necesita un `uow_factory` construido con *tu* engine, cosa que no se puede expresar
326
+ > como dotted path: instancialo a mano y pasá el pipeline al bus.
327
+ >
328
+ > ```python
329
+ > TransactionMiddleware(uow_factory=lambda: SqlAlchemyUnitOfWork(session=session_factory()))
330
+ > ```
331
+
322
332
  ---
323
333
 
324
334
  ### Guía de Migración: De Casos de Uso Clásicos a CQRS
@@ -252,7 +252,7 @@ HexCore v2 integra de forma nativa soporte para el patrón **CQRS (Command Query
252
252
 
253
253
  El sistema se basa en 3 buses principales, configurables e independientes:
254
254
 
255
- 1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema y se ejecutan (por defecto) dentro de una transacción de base de datos (Unit of Work).
255
+ 1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema. La transacción la gestiona el handler (el patrón que enseñan los ejemplos de use case); si preferís que la gestione el bus, añadí `TransactionMiddleware` explícitamente con su `uow_factory`.
256
256
  2. **`AbstractQueryBus`**: Despacha intenciones de lectura (`Query`) a un único `AbstractQueryHandler`. Retornan un resultado sin mutar el estado.
257
257
  3. **`EventBus`**: Distribuye eventos de dominio (`DomainEvent`) a múltiples suscriptores asíncronamente (vía `subscribe`/`publish`).
258
258
 
@@ -265,8 +265,9 @@ from hexcore.application.cqrs.config import CQRSConfig, BusConfig
265
265
  config = ServerConfig(
266
266
  cqrs=CQRSConfig(
267
267
  command_bus=BusConfig(
268
- # Por defecto incluye TransactionMiddleware
269
- middlewares=["hexcore.infrastructure.cqrs.middlewares.TransactionMiddleware"]
268
+ # Sin middlewares por defecto. Los que no necesitan configuración se
269
+ # pueden declarar por dotted path:
270
+ middlewares=["hexcore.infrastructure.cqrs.middlewares.LoggingMiddleware"]
270
271
  ),
271
272
  # Puedes sustituir el backend en memoria por uno distribuido (Ej: Celery, Procrastinate)
272
273
  # backend="mi_app.infrastructure.ProcrastinateCommandBus"
@@ -274,6 +275,15 @@ config = ServerConfig(
274
275
  )
275
276
  ```
276
277
 
278
+ > **`TransactionMiddleware` no es el default.** Comitea después del handler, así que
279
+ > con un handler que ya gestiona su propia transacción comitearías dos veces. Y
280
+ > necesita un `uow_factory` construido con *tu* engine, cosa que no se puede expresar
281
+ > como dotted path: instancialo a mano y pasá el pipeline al bus.
282
+ >
283
+ > ```python
284
+ > TransactionMiddleware(uow_factory=lambda: SqlAlchemyUnitOfWork(session=session_factory()))
285
+ > ```
286
+
277
287
  ---
278
288
 
279
289
  ### Guía de Migración: De Casos de Uso Clásicos a CQRS
@@ -55,13 +55,12 @@ class CQRSConfig(BaseModel):
55
55
  """
56
56
 
57
57
  enabled: bool = True
58
- command_bus: BusConfig = Field(
59
- default_factory=lambda: BusConfig(
60
- middlewares=[
61
- "hexcore.infrastructure.cqrs.middlewares.TransactionMiddleware"
62
- ]
63
- )
64
- )
58
+ # Sin middlewares por defecto (P0-6). `TransactionMiddleware` *era* el default,
59
+ # pero adivinaba la sesión con el session factory interno de HexCore en vez del
60
+ # engine de la aplicación, y comiteaba por segunda vez sobre los handlers que ya
61
+ # gestionan su transacción. Si lo querés, declaralo explícitamente con su
62
+ # `uow_factory`.
63
+ command_bus: BusConfig = Field(default_factory=BusConfig)
65
64
  query_bus: BusConfig = Field(default_factory=BusConfig)
66
65
  event_bus: BusConfig = Field(default_factory=BusConfig)
67
66
  serializer: t.Optional[str] = None # None = PydanticSerializer por defecto
@@ -0,0 +1,215 @@
1
+ """
2
+ Factory para construir buses CQRS a partir de la configuración.
3
+ Resuelve backends, serializers y middlewares por dotted path.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import importlib
8
+ import typing as t
9
+
10
+ from hexcore.domain.cqrs.buses import ICommandBus, IQueryBus, IEventBus
11
+ from hexcore.domain.cqrs.middleware import IMiddleware
12
+ from hexcore.domain.cqrs.serializer import ISerializer
13
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
14
+
15
+ from .config import CQRSConfig, BusConfig
16
+ from .registry import HandlerRegistry
17
+ from .pipeline import MiddlewarePipeline
18
+ from .in_memory_buses import InMemoryCommandBus, InMemoryQueryBus, InMemoryEventBus
19
+
20
+
21
+ def _import_class(dotted_path: str) -> type:
22
+ """Importa una clase a partir de su dotted path."""
23
+ module_path, class_name = dotted_path.rsplit(".", 1)
24
+ module = importlib.import_module(module_path)
25
+ return getattr(module, class_name)
26
+
27
+
28
+ def _build_middlewares(dotted_paths: list[str]) -> list[IMiddleware]:
29
+ """
30
+ Instancia middlewares a partir de sus dotted paths.
31
+
32
+ Sólo sirve para middlewares construibles sin argumentos. Los que necesitan
33
+ configuración (p. ej. `TransactionMiddleware`, que requiere un `uow_factory`
34
+ atado al engine de la aplicación) hay que instanciarlos a mano y pasar el
35
+ `MiddlewarePipeline` al bus.
36
+ """
37
+ middlewares: list[IMiddleware] = []
38
+ for path in dotted_paths:
39
+ cls = _import_class(path)
40
+ try:
41
+ middlewares.append(cls())
42
+ except TypeError as exc:
43
+ raise TypeError(
44
+ f"El middleware '{path}' no se puede construir sin argumentos: {exc}. "
45
+ "Declararlo por dotted path sólo funciona para middlewares sin "
46
+ "configuración; instancialo a mano y pasá el MiddlewarePipeline al bus."
47
+ ) from exc
48
+ except ValueError as exc:
49
+ raise ValueError(
50
+ f"El middleware '{path}' rechazó su construcción por defecto: {exc} "
51
+ "Declararlo por dotted path sólo funciona para middlewares sin "
52
+ "configuración; instancialo a mano y pasá el MiddlewarePipeline al bus."
53
+ ) from exc
54
+ return middlewares
55
+
56
+
57
+ def _build_pipeline(bus_config: BusConfig) -> MiddlewarePipeline:
58
+ """Construye un MiddlewarePipeline desde la configuración de un bus."""
59
+ middlewares = _build_middlewares(bus_config.middlewares)
60
+ return MiddlewarePipeline(middlewares)
61
+
62
+
63
+ class CQRSFactory:
64
+ """
65
+ Factory que construye las instancias de buses CQRS.
66
+
67
+ Usa CQRSConfig para determinar qué implementación de bus instanciar,
68
+ qué middlewares configurar y qué serializer utilizar.
69
+
70
+ Uso::
71
+
72
+ config = CQRSConfig(...)
73
+ registry = HandlerRegistry()
74
+ # ...registrar handlers...
75
+
76
+ factory = CQRSFactory(config, registry, enqueuer=ProcrastinateEnqueuer(app))
77
+ command_bus = factory.create_command_bus()
78
+ query_bus = factory.create_query_bus()
79
+ event_bus = factory.create_event_bus()
80
+
81
+ El `enqueuer` es lo que habilita el Smart Routing: sin él, los buses in-memory
82
+ no pueden enrutar un `@background_command` y la factory lo dice **al construir**,
83
+ no en el primer dispatch.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ config: CQRSConfig,
89
+ registry: HandlerRegistry,
90
+ enqueuer: ITaskEnqueuer | None = None,
91
+ ) -> None:
92
+ self._config = config
93
+ self._registry = registry
94
+ self._enqueuer = enqueuer
95
+ self._serializer: ISerializer | None = None
96
+
97
+ def create_serializer(self) -> ISerializer:
98
+ """
99
+ Crea el serializer configurado (PydanticSerializer por defecto).
100
+
101
+ La instancia se cachea: los buses y el consumer tienen que compartir el
102
+ mismo serializer para que el round-trip por la cola sea coherente.
103
+ """
104
+ if self._serializer is not None:
105
+ return self._serializer
106
+
107
+ if self._config.serializer:
108
+ cls = _import_class(self._config.serializer)
109
+ self._serializer = t.cast(ISerializer, cls())
110
+ else:
111
+ from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
112
+
113
+ self._serializer = PydanticSerializer()
114
+ return self._serializer
115
+
116
+ def create_command_bus(self, **extra_kwargs: t.Any) -> ICommandBus:
117
+ """
118
+ Crea el CommandBus configurado.
119
+ Si no hay backend explícito, retorna InMemoryCommandBus con el enqueuer y el
120
+ serializer necesarios para el Smart Routing.
121
+ """
122
+ bus_config = self._config.command_bus
123
+ pipeline = _build_pipeline(bus_config)
124
+
125
+ if bus_config.backend is None:
126
+ self._assert_enqueuer_for_background_commands()
127
+ return InMemoryCommandBus(
128
+ registry=self._registry,
129
+ pipeline=pipeline,
130
+ enqueuer=self._enqueuer,
131
+ serializer=self.create_serializer(),
132
+ **extra_kwargs,
133
+ )
134
+
135
+ # Backend personalizado (ej. ProcrastinateCommandBus)
136
+ cls = _import_class(bus_config.backend)
137
+ return cls(
138
+ registry=self._registry,
139
+ serializer=self.create_serializer(),
140
+ pipeline=pipeline,
141
+ **bus_config.options,
142
+ **extra_kwargs,
143
+ )
144
+
145
+ def create_query_bus(self) -> IQueryBus:
146
+ """
147
+ Crea el QueryBus configurado.
148
+ Nota: Las queries siempre son síncronas en CQRS puro.
149
+ """
150
+ bus_config = self._config.query_bus
151
+ pipeline = _build_pipeline(bus_config)
152
+
153
+ if bus_config.backend is None:
154
+ return InMemoryQueryBus(
155
+ registry=self._registry,
156
+ pipeline=pipeline,
157
+ )
158
+
159
+ cls = _import_class(bus_config.backend)
160
+ return cls(
161
+ registry=self._registry,
162
+ pipeline=pipeline,
163
+ **bus_config.options,
164
+ )
165
+
166
+ def create_event_bus(self, **extra_kwargs: t.Any) -> IEventBus:
167
+ """
168
+ Crea el EventBus configurado.
169
+
170
+ Al bus in-memory se le pasan enqueuer y serializer para que los suscriptores
171
+ marcados con `@background_handler` se puedan enrutar.
172
+ """
173
+ bus_config = self._config.event_bus
174
+ pipeline = _build_pipeline(bus_config)
175
+
176
+ if bus_config.backend is None:
177
+ return InMemoryEventBus(
178
+ pipeline=pipeline,
179
+ enqueuer=self._enqueuer,
180
+ serializer=self.create_serializer(),
181
+ **extra_kwargs,
182
+ )
183
+
184
+ cls = _import_class(bus_config.backend)
185
+ return cls(
186
+ pipeline=pipeline,
187
+ **bus_config.options,
188
+ **extra_kwargs,
189
+ )
190
+
191
+ # ── Validación ────────────────────────────────────────────────────────────
192
+
193
+ def _assert_enqueuer_for_background_commands(self) -> None:
194
+ """
195
+ Falla al construir si hay commands de background registrados y no hay
196
+ enqueuer. El error en el primer dispatch llega demasiado tarde: para
197
+ entonces la petición del usuario ya está en vuelo.
198
+ """
199
+ if self._enqueuer is not None:
200
+ return
201
+
202
+ background = [
203
+ command_type.__name__
204
+ for command_type in self._registry.registered_commands
205
+ if getattr(command_type, "__cqrs_background__", False)
206
+ ]
207
+ if not background:
208
+ return
209
+
210
+ raise RuntimeError(
211
+ "CQRSFactory no tiene 'enqueuer', pero hay commands decorados con "
212
+ f"@background_command: {', '.join(sorted(background))}. "
213
+ "Pasá un ITaskEnqueuer al construir la factory, p. ej. "
214
+ "CQRSFactory(config, registry, enqueuer=ProcrastinateEnqueuer(app))."
215
+ )
@@ -8,6 +8,7 @@ import logging
8
8
 
9
9
  from hexcore.domain.cqrs.buses import ICommandBus, IQueryBus, IEventBus
10
10
  from hexcore.domain.cqrs.commands import Command
11
+ from hexcore.domain.cqrs.context import is_worker_execution, local_execution
11
12
  from hexcore.domain.cqrs.queries import Query
12
13
  from hexcore.domain.events import DomainEvent
13
14
  from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
@@ -28,6 +29,10 @@ class InMemoryCommandBus(ICommandBus):
28
29
  Si el comando está decorado con `@background_command` y se provee un `enqueuer`,
29
30
  el comando es automáticamente encolado para su ejecución en segundo plano
30
31
  sin bloquear el proceso actual (retornando None).
32
+
33
+ Cuando el mensaje viene de un worker (``IN_WORKER`` activo, lo pone el
34
+ ``CQRSConsumer``), el bus lo ejecuta **localmente** en vez de reencolarlo.
35
+ Esto permite usar el mismo bus en el proceso web y en el worker.
31
36
  """
32
37
 
33
38
  def __init__(
@@ -44,17 +49,19 @@ class InMemoryCommandBus(ICommandBus):
44
49
 
45
50
  async def dispatch(self, command: Command) -> t.Any:
46
51
  cmd_type = type(command)
47
-
52
+
48
53
  # 1. Smart Routing: ¿Debe irse a background?
54
+ # Si ya estamos dentro de un worker, el mensaje viene de la cola: hay que
55
+ # ejecutarlo, no volver a encolarlo.
49
56
  is_background = getattr(cmd_type, "__cqrs_background__", False)
50
-
51
- if is_background:
57
+
58
+ if is_background and not is_worker_execution():
52
59
  if not self._enqueuer or not self._serializer:
53
60
  raise RuntimeError(
54
61
  f"El comando '{cmd_type.__name__}' requiere ejecución en background, "
55
62
  "pero el InMemoryCommandBus no tiene configurado un 'enqueuer' o 'serializer'."
56
63
  )
57
-
64
+
58
65
  async def background_dispatcher(cmd: Command) -> None:
59
66
  queue_name = getattr(cmd_type, "__cqrs_queue__", "default")
60
67
  payload = self._serializer.serialize(cmd) # type: ignore
@@ -70,7 +77,10 @@ class InMemoryCommandBus(ICommandBus):
70
77
  async def final_handler(cmd: t.Any) -> t.Any:
71
78
  return await handler.handle(cmd)
72
79
 
73
- return await self._pipeline.execute(command, final_handler)
80
+ # `local_execution` consume el flag de worker: si el handler despacha otro
81
+ # `@background_command`, ese sí debe encolarse.
82
+ with local_execution():
83
+ return await self._pipeline.execute(command, final_handler)
74
84
 
75
85
 
76
86
  class InMemoryQueryBus(IQueryBus):
@@ -129,9 +139,15 @@ class InMemoryEventBus(IEventBus):
129
139
 
130
140
  async def publish(self, event: DomainEvent) -> None:
131
141
  handlers = self._handlers.get(type(event), [])
142
+ # Si el evento viene de un worker, sus handlers de background se ejecutan
143
+ # aquí; reencolarlos sería un bucle infinito.
144
+ in_worker = is_worker_execution()
132
145
 
133
146
  for event_handler in handlers:
134
- is_background = getattr(event_handler, "__cqrs_background_handler__", False)
147
+ is_background = (
148
+ getattr(event_handler, "__cqrs_background_handler__", False)
149
+ and not in_worker
150
+ )
135
151
 
136
152
  if is_background:
137
153
  # Enrutamiento hacia background
@@ -161,4 +177,5 @@ class InMemoryEventBus(IEventBus):
161
177
  ) -> None:
162
178
  await _h(evt)
163
179
 
164
- await self._pipeline.execute(event, final_handler)
180
+ with local_execution():
181
+ await self._pipeline.execute(event, final_handler)
@@ -0,0 +1,185 @@
1
+ """
2
+ Registro central de handlers CQRS.
3
+ Mapea tipos de Command/Query a sus handlers correspondientes.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+ import typing as t
9
+
10
+ from hexcore.domain.cqrs.commands import Command
11
+ from hexcore.domain.cqrs.queries import Query
12
+ from hexcore.domain.cqrs.handlers import ICommandHandler, IQueryHandler
13
+ from hexcore.domain.cqrs.exceptions import HandlerNotFoundError, DuplicateHandlerError
14
+
15
+
16
+ # Tipos para factories de handlers (para DI/lazy instantiation)
17
+ CommandHandlerFactory = t.Callable[[], ICommandHandler[t.Any, t.Any]]
18
+ QueryHandlerFactory = t.Callable[[], IQueryHandler[t.Any, t.Any]]
19
+
20
+ TFactory = t.TypeVar("TFactory")
21
+
22
+
23
+ class HandlerFactory(t.Generic[TFactory]):
24
+ """
25
+ Marcador explícito de "esto es un factory, no un handler".
26
+
27
+ `callable(entry) and not isinstance(entry, ICommandHandler)` es ambiguo: un handler
28
+ que implemente `__call__` se confundiría con un factory, y un factory que herede de
29
+ la interfaz se confundiría con un handler. Envolver el callable elimina la
30
+ heurística.
31
+
32
+ No es obligatorio: registrar un `lambda` sigue funcionando (se detecta por
33
+ heurística, igual que antes) y `HandlerRegistry.factory()` construye el marcador
34
+ por ti.
35
+ """
36
+
37
+ __slots__ = ("build",)
38
+
39
+ def __init__(self, build: t.Callable[[], TFactory]) -> None:
40
+ self.build = build
41
+
42
+ def __call__(self) -> TFactory:
43
+ return self.build()
44
+
45
+
46
+ class HandlerRegistry:
47
+ """
48
+ Registro de handlers para Commands y Queries.
49
+
50
+ Soporta dos modos de registro:
51
+
52
+ 1. Registro directo de instancias (eager)
53
+ 2. Registro de factories (lazy, para DI containers)
54
+
55
+ **Thread-safety.** El registro y la resolución están protegidos por un
56
+ `threading.RLock`. Hace falta porque `resolve_*` hace lazy-init con escritura en el
57
+ dict: sin lock, dos hilos (o dos hilos reales bajo el free-threading de Python
58
+ 3.14) pueden instanciar el mismo handler dos veces y quedarse cada uno con la suya.
59
+
60
+ Uso::
61
+
62
+ registry = HandlerRegistry()
63
+
64
+ # Registro directo
65
+ registry.register_command_handler(CreateUserCommand, CreateUserHandler())
66
+
67
+ # Registro con factory (lazy) — marcador explícito
68
+ registry.register_command_handler(
69
+ CreateUserCommand, HandlerRegistry.factory(lambda: container.get(CreateUserHandler))
70
+ )
71
+
72
+ # Resolución
73
+ handler = registry.resolve_command_handler(CreateUserCommand)
74
+ """
75
+
76
+ def __init__(self, *, allow_override: bool = False) -> None:
77
+ self._command_handlers: dict[
78
+ type[Command], ICommandHandler[t.Any, t.Any] | CommandHandlerFactory
79
+ ] = {}
80
+ self._query_handlers: dict[
81
+ type[Query[t.Any]], IQueryHandler[t.Any, t.Any] | QueryHandlerFactory
82
+ ] = {}
83
+ self._allow_override = allow_override
84
+ # Reentrante: un factory puede resolver otro handler del mismo registry.
85
+ self._lock = threading.RLock()
86
+
87
+ # ── Factories ─────────────────────────────────────────────────────────────
88
+
89
+ @staticmethod
90
+ def factory(build: t.Callable[[], t.Any]) -> HandlerFactory[t.Any]:
91
+ """
92
+ Envuelve un callable para registrarlo como factory sin ambigüedad.
93
+
94
+ Preferí esto a pasar el callable pelado cuando tu handler implementa
95
+ `__call__`, porque entonces la heurística no puede distinguirlos.
96
+ """
97
+ return HandlerFactory(build)
98
+
99
+ # ── Command Handlers ──────────────────────────────────────────
100
+
101
+ def register_command_handler(
102
+ self,
103
+ command_type: type[Command],
104
+ handler: ICommandHandler[t.Any, t.Any] | CommandHandlerFactory,
105
+ ) -> "HandlerRegistry":
106
+ """Registra un handler (o factory) para un tipo de command. Retorna self para fluent API."""
107
+ with self._lock:
108
+ if not self._allow_override and command_type in self._command_handlers:
109
+ raise DuplicateHandlerError(command_type)
110
+ self._command_handlers[command_type] = handler
111
+ return self
112
+
113
+ def resolve_command_handler(
114
+ self, command_type: type[Command]
115
+ ) -> ICommandHandler[t.Any, t.Any]:
116
+ """Resuelve el handler para el tipo de command dado."""
117
+ with self._lock:
118
+ entry = self._command_handlers.get(command_type)
119
+ if entry is None:
120
+ raise HandlerNotFoundError(command_type)
121
+ if _is_factory(entry, ICommandHandler):
122
+ # Es un factory, invocar y cachear la instancia
123
+ handler = t.cast(CommandHandlerFactory, entry)()
124
+ self._command_handlers[command_type] = handler
125
+ return handler
126
+ return t.cast(ICommandHandler[t.Any, t.Any], entry)
127
+
128
+ # ── Query Handlers ────────────────────────────────────────────
129
+
130
+ def register_query_handler(
131
+ self,
132
+ query_type: type[Query[t.Any]],
133
+ handler: IQueryHandler[t.Any, t.Any] | QueryHandlerFactory,
134
+ ) -> "HandlerRegistry":
135
+ """Registra un handler (o factory) para un tipo de query. Retorna self para fluent API."""
136
+ with self._lock:
137
+ if not self._allow_override and query_type in self._query_handlers:
138
+ raise DuplicateHandlerError(query_type)
139
+ self._query_handlers[query_type] = handler
140
+ return self
141
+
142
+ def resolve_query_handler(
143
+ self, query_type: type[Query[t.Any]]
144
+ ) -> IQueryHandler[t.Any, t.Any]:
145
+ """Resuelve el handler para el tipo de query dado."""
146
+ with self._lock:
147
+ entry = self._query_handlers.get(query_type)
148
+ if entry is None:
149
+ raise HandlerNotFoundError(query_type)
150
+ if _is_factory(entry, IQueryHandler):
151
+ handler = t.cast(QueryHandlerFactory, entry)()
152
+ self._query_handlers[query_type] = handler
153
+ return handler
154
+ return t.cast(IQueryHandler[t.Any, t.Any], entry)
155
+
156
+ # ── Introspección ─────────────────────────────────────────────
157
+
158
+ @property
159
+ def registered_commands(self) -> frozenset[type[Command]]:
160
+ """Retorna los tipos de command registrados."""
161
+ with self._lock:
162
+ return frozenset(self._command_handlers.keys())
163
+
164
+ @property
165
+ def registered_queries(self) -> frozenset[type[Query[t.Any]]]:
166
+ """Retorna los tipos de query registrados."""
167
+ with self._lock:
168
+ return frozenset(self._query_handlers.keys())
169
+
170
+
171
+ def _is_factory(entry: t.Any, handler_interface: type) -> bool:
172
+ """
173
+ Decide si `entry` es un factory de handlers o el handler mismo.
174
+
175
+ El marcador `HandlerFactory` es inequívoco. Para los callables pelados se mantiene
176
+ la heurística anterior por retrocompatibilidad, con una comprobación extra: un
177
+ objeto con método `handle` es un handler aunque además sea callable.
178
+ """
179
+ if isinstance(entry, HandlerFactory):
180
+ return True
181
+ if isinstance(entry, handler_interface):
182
+ return False
183
+ if hasattr(entry, "handle"):
184
+ return False
185
+ return callable(entry)