hexcore 2.3.0__tar.gz → 2.5.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 (118) hide show
  1. {hexcore-2.3.0 → hexcore-2.5.0}/PKG-INFO +146 -32
  2. hexcore-2.3.0/hexcore.egg-info/PKG-INFO → hexcore-2.5.0/README.md +116 -47
  3. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/cqrs/__init__.py +2 -0
  4. hexcore-2.5.0/hexcore/application/cqrs/scheduler.py +99 -0
  5. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/__init__.py +5 -0
  6. hexcore-2.5.0/hexcore/domain/cqrs/cron.py +69 -0
  7. hexcore-2.5.0/hexcore/infrastructure/cqrs/postgres_bus.py +126 -0
  8. hexcore-2.5.0/hexcore/infrastructure/cqrs/postgres_lock.py +95 -0
  9. hexcore-2.5.0/hexcore/infrastructure/cqrs/redis_bus.py +143 -0
  10. hexcore-2.5.0/hexcore/infrastructure/cqrs/redis_lock.py +62 -0
  11. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/base.py +6 -3
  12. hexcore-2.5.0/hexcore/infrastructure/repositories/implementations.py +198 -0
  13. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/utils.py +15 -6
  14. hexcore-2.5.0/hexcore/infrastructure/task_queues/__init__.py +3 -0
  15. hexcore-2.5.0/hexcore/infrastructure/task_queues/celery_adapter.py +83 -0
  16. hexcore-2.5.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +61 -0
  17. hexcore-2.5.0/hexcore/infrastructure/uow/__init__.py +189 -0
  18. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/types.py +4 -1
  19. hexcore-2.3.0/README.md → hexcore-2.5.0/hexcore.egg-info/PKG-INFO +161 -21
  20. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore.egg-info/SOURCES.txt +16 -0
  21. hexcore-2.5.0/hexcore.egg-info/requires.txt +42 -0
  22. {hexcore-2.3.0 → hexcore-2.5.0}/pyproject.toml +27 -10
  23. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_beanie_query_utils.py +2 -0
  24. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_infrastructure_query_path.py +3 -0
  25. hexcore-2.5.0/tests/test_optional_dependencies.py +50 -0
  26. hexcore-2.5.0/tests/test_postgres_bus.py +73 -0
  27. hexcore-2.5.0/tests/test_postgres_lock.py +77 -0
  28. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_query_field_validation.py +2 -0
  29. hexcore-2.5.0/tests/test_redis_bus.py +75 -0
  30. hexcore-2.5.0/tests/test_redis_lock.py +69 -0
  31. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_repositories_utils.py +2 -0
  32. hexcore-2.5.0/tests/test_scheduler.py +115 -0
  33. hexcore-2.5.0/tests/test_task_queues_adapters.py +82 -0
  34. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_uow_session_regression.py +2 -0
  35. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_use_cases_query.py +2 -0
  36. hexcore-2.3.0/hexcore/infrastructure/repositories/implementations.py +0 -212
  37. hexcore-2.3.0/hexcore/infrastructure/uow/__init__.py +0 -181
  38. hexcore-2.3.0/hexcore.egg-info/requires.txt +0 -17
  39. {hexcore-2.3.0 → hexcore-2.5.0}/LICENSE +0 -0
  40. {hexcore-2.3.0 → hexcore-2.5.0}/MANIFEST.in +0 -0
  41. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/__init__.py +0 -0
  42. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/__main__.py +0 -0
  43. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/__init__.py +0 -0
  44. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/cqrs/adapters.py +0 -0
  45. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/cqrs/config.py +0 -0
  46. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/cqrs/factory.py +0 -0
  47. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
  48. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/cqrs/pipeline.py +0 -0
  49. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/cqrs/registry.py +0 -0
  50. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/dtos/__init__.py +0 -0
  51. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/dtos/base.py +0 -0
  52. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/dtos/query.py +0 -0
  53. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/use_cases/__init__.py +0 -0
  54. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/use_cases/base.py +0 -0
  55. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/application/use_cases/query.py +0 -0
  56. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/config.py +0 -0
  57. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/__init__.py +0 -0
  58. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/auth/__init__.py +0 -0
  59. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/auth/permissions.py +0 -0
  60. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/auth/value_objects.py +0 -0
  61. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/base.py +0 -0
  62. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/buses.py +0 -0
  63. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/commands.py +0 -0
  64. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/decorators.py +0 -0
  65. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  66. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/handlers.py +0 -0
  67. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/middleware.py +0 -0
  68. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/queries.py +0 -0
  69. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/serializer.py +0 -0
  70. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/cqrs/task_queues.py +0 -0
  71. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/events.py +0 -0
  72. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/exceptions.py +0 -0
  73. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/repositories.py +0 -0
  74. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/services.py +0 -0
  75. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/domain/uow.py +0 -0
  76. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/__init__.py +0 -0
  77. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/api/__init__.py +0 -0
  78. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/api/utils.py +0 -0
  79. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  80. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  81. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  82. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  83. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cli.py +0 -0
  84. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  85. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
  86. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  87. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  88. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  89. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/events/__init__.py +0 -0
  90. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  91. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  92. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  93. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  94. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  95. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  96. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  97. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  98. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  99. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  100. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  101. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  102. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/workers/__init__.py +0 -0
  103. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/workers/consumer.py +0 -0
  104. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
  105. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore/py.typed +0 -0
  106. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore.egg-info/dependency_links.txt +0 -0
  107. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore.egg-info/entry_points.txt +0 -0
  108. {hexcore-2.3.0 → hexcore-2.5.0}/hexcore.egg-info/top_level.txt +0 -0
  109. {hexcore-2.3.0 → hexcore-2.5.0}/scripts/__init__.py +0 -0
  110. {hexcore-2.3.0 → hexcore-2.5.0}/scripts/main.py +0 -0
  111. {hexcore-2.3.0 → hexcore-2.5.0}/setup.cfg +0 -0
  112. {hexcore-2.3.0 → hexcore-2.5.0}/tests/conftest.py +0 -0
  113. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_basic.py +0 -0
  114. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_config_loading.py +0 -0
  115. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_cqrs.py +0 -0
  116. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_domain_service_query.py +0 -0
  117. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_rabbitmq_bus.py +0 -0
  118. {hexcore-2.3.0 → hexcore-2.5.0}/tests/test_smart_routing.py +0 -0
@@ -1,27 +1,46 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 2.3.0
3
+ Version: 2.5.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
7
7
  Requires-Python: >=3.12
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
- Requires-Dist: aiosqlite>=0.21.0
11
- Requires-Dist: alembic>=1.16.5
12
- Requires-Dist: asyncpg>=0.30.0
13
- Requires-Dist: beanie>=2.0.0
14
- Requires-Dist: fastapi>=0.116.1
15
- Requires-Dist: pika>=1.3.2
10
+ Requires-Dist: croniter>=2.0.0
16
11
  Requires-Dist: pydantic>=2.11.7
17
- Requires-Dist: redis>=6.4.0
18
- Requires-Dist: sqlalchemy>=2.0.43
19
12
  Requires-Dist: typer>=0.17.3
20
13
  Requires-Dist: ruff>=0.12.11
21
- Provides-Extra: procrastinate
22
- Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
14
+ Provides-Extra: api
15
+ Requires-Dist: fastapi>=0.116.1; extra == "api"
16
+ Provides-Extra: redis
17
+ Requires-Dist: redis>=6.4.0; extra == "redis"
18
+ Provides-Extra: mongo
19
+ Requires-Dist: beanie>=2.0.0; extra == "mongo"
20
+ Provides-Extra: sql
21
+ Requires-Dist: sqlalchemy>=2.0.43; extra == "sql"
22
+ Requires-Dist: alembic>=1.16.5; extra == "sql"
23
+ Requires-Dist: asyncpg>=0.30.0; extra == "sql"
24
+ Requires-Dist: aiosqlite>=0.21.0; extra == "sql"
23
25
  Provides-Extra: rabbitmq
24
26
  Requires-Dist: aio-pika>=9.4.0; extra == "rabbitmq"
27
+ Requires-Dist: pika>=1.3.2; extra == "rabbitmq"
28
+ Provides-Extra: procrastinate
29
+ Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
30
+ Provides-Extra: celery
31
+ Requires-Dist: celery>=5.4.0; extra == "celery"
32
+ Provides-Extra: all
33
+ Requires-Dist: fastapi>=0.116.1; extra == "all"
34
+ Requires-Dist: redis>=6.4.0; extra == "all"
35
+ Requires-Dist: beanie>=2.0.0; extra == "all"
36
+ Requires-Dist: sqlalchemy>=2.0.43; extra == "all"
37
+ Requires-Dist: alembic>=1.16.5; extra == "all"
38
+ Requires-Dist: asyncpg>=0.30.0; extra == "all"
39
+ Requires-Dist: aiosqlite>=0.21.0; extra == "all"
40
+ Requires-Dist: aio-pika>=9.4.0; extra == "all"
41
+ Requires-Dist: pika>=1.3.2; extra == "all"
42
+ Requires-Dist: procrastinate>=3.0.0; extra == "all"
43
+ Requires-Dist: celery>=5.4.0; extra == "all"
25
44
  Dynamic: license-file
26
45
 
27
46
  # HexCore [![PyPI Downloads](https://static.pepy.tech/personalized-badge/hexcore?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/hexcore)
@@ -516,23 +535,39 @@ class ProcrastinateEnqueuer(ITaskEnqueuer):
516
535
  await process_generic_task.defer_async(task_name=task_name, payload=payload)
517
536
  ```
518
537
 
519
- #### 3. Configurar tus Buses (Enrutamiento Automático)
538
+ #### 2. Configurar tus Buses con un Adaptador Oficial
520
539
 
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).
540
+ HexCore provee adaptadores *plug & play* para **Celery** y **Procrastinate**. Simplemente importa el enqueuer, pásale tu app y configúralo en los buses de memoria.
522
541
 
523
- ```python
524
- from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus, InMemoryEventBus
542
+ Si además deseas persistencia o distribución de Eventos entre múltiples workers/servidores (Pub/Sub), puedes cambiar el `InMemoryEventBus` por `RedisEventBus`, `PostgresEventBus` o `RabbitMQEventBus`:
525
543
 
526
- enqueuer = ProcrastinateEnqueuer()
544
+ ```python
545
+ from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus
546
+ from hexcore.infrastructure.task_queues.celery_adapter import CeleryEnqueuer
547
+ from hexcore.infrastructure.cqrs.redis_bus import RedisEventBus
548
+ from celery import Celery
549
+ import redis.asyncio as redis
550
+
551
+ # 1. Adaptador de Task Queue (Para Comandos asíncronos y Event Handlers asíncronos)
552
+ app = Celery("my_app", broker="redis://localhost:6379/0")
553
+ enqueuer = CeleryEnqueuer(app)
527
554
  serializer = PydanticSerializer()
528
555
 
529
- # El bus evalúa: ¿Tiene el comando @background_command? Si es así, usa el enqueuer.
530
556
  command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
531
557
 
532
- # El bus evalúa suscriptores: ¿Tienen @background_handler? Si es así, usa el enqueuer.
533
- event_bus = InMemoryEventBus(enqueuer=enqueuer, serializer=serializer)
558
+ # 2. Event Bus (Para enviar los Eventos por la red)
559
+ redis_client = redis.from_url("redis://localhost:6379/0")
560
+ event_bus = RedisEventBus(
561
+ redis_client=redis_client,
562
+ serializer=serializer,
563
+ stream_name="hexcore:events",
564
+ group_name="api_workers",
565
+ enqueuer=enqueuer # <-- Importante para inyectarle la habilidad de Smart Routing
566
+ )
534
567
  ```
535
568
 
569
+ > **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.
570
+
536
571
  #### 4. Ejecutar tareas genéricas
537
572
 
538
573
  Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
@@ -548,10 +583,11 @@ await enqueuer.enqueue_task(
548
583
 
549
584
  #### 5. Levantar el Worker (Consumidor Universal)
550
585
 
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.
586
+ En el entrypoint de tu worker, usa la función utilitaria `register_hexcore_celery_tasks` para autoconfigurar las rutas en una sola línea:
552
587
 
553
588
  ```python
554
589
  from hexcore.infrastructure.workers.consumer import CQRSConsumer
590
+ from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
555
591
 
556
592
  consumer = CQRSConsumer(
557
593
  command_bus=command_bus, # Tu CommandBus configurado
@@ -559,20 +595,98 @@ consumer = CQRSConsumer(
559
595
  serializer=serializer
560
596
  )
561
597
 
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)
598
+ # ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
599
+ register_hexcore_celery_tasks(app, consumer)
600
+ ```
601
+
602
+ ---
603
+
604
+ ## Tareas Periódicas Dinámicas (Cronjobs en Caliente)
605
+
606
+ HexCore incluye un **`DynamicScheduler`** que te permite programar tareas (`@background_task`) para que se ejecuten periódicamente. La ventaja clave es que lee la configuración desde un repositorio (como tu Base de Datos), permitiendo activar, desactivar o cambiar los horarios **sin necesidad de reiniciar tus servidores**.
607
+
608
+ ### 1. Implementa tu Repositorio
609
+ Implementa `ICronJobRepository` para decirle al Scheduler de dónde leer la configuración (ej. usando SQLAlchemy, MongoDB o Redis):
610
+
611
+ ```python
612
+ from hexcore.domain.cqrs.cron import ICronJobRepository, CronJobDefinition
613
+ from datetime import datetime
614
+
615
+ class MiCronRepository(ICronJobRepository):
616
+ async def get_active_jobs(self) -> list[CronJobDefinition]:
617
+ # SELECT * FROM cronjobs WHERE is_active = true
618
+ return [
619
+ CronJobDefinition(
620
+ job_id="1",
621
+ task_name="hexcore.process_task", # o cualquier @background_task
622
+ cron_expression="*/5 * * * *", # cada 5 minutos
623
+ payload={"task_name": "clean_db", "payload": {}}
624
+ )
625
+ ]
626
+
627
+ async def update_last_run(self, job_id: str, run_time: datetime) -> None:
628
+ # UPDATE cronjobs SET last_run = run_time WHERE id = job_id
629
+ pass
630
+ ```
631
+
632
+ ### 2. Levanta el Scheduler
633
+ En un proceso en background de tu API o en un microservicio separado, arranca el Scheduler inyectándole tu Enqueuer favorito (Celery, Procrastinate):
634
+
635
+ ```python
636
+ from hexcore.application.cqrs.scheduler import DynamicScheduler
637
+ import asyncio
638
+
639
+ async def run_scheduler():
640
+ repo = MiCronRepository()
641
+ scheduler = DynamicScheduler(
642
+ repository=repo,
643
+ enqueuer=enqueuer,
644
+ tick_interval_seconds=60
645
+ )
646
+
647
+ await scheduler.start() # Bucle infinito
648
+
649
+ # En FastAPI puedes usar lifespan para lanzarlo:
650
+ # asyncio.create_task(run_scheduler())
651
+ ```
652
+
653
+ El Scheduler evaluará las expresiones (gracias a `croniter`) y delegará la carga pesada al enqueuer. ¡Tu Worker no necesita saber de horarios, solo ejecuta las tareas cuando le llegan!
654
+
655
+ ### 3. Distributed Locks (Evitar ejecuciones dobles)
656
+
657
+ Si corres tu aplicación en múltiples contenedores o réplicas (ej. Kubernetes), podrías tener múltiples instancias del `DynamicScheduler` ejecutándose al mismo tiempo. Para evitar que el mismo cronjob se encole dos veces en el mismo minuto, HexCore soporta **Locks Distribuidos**.
658
+
659
+ Puedes inyectar un proveedor de locks (`ILockProvider`) usando Redis o PostgreSQL (si lo usas como tu DB). Al inyectarlo, el Scheduler bloqueará atómicamente la tarea a través de toda tu red.
660
+
661
+ #### Usando Redis
662
+ ```python
663
+ from hexcore.infrastructure.cqrs.redis_lock import RedisLockProvider
664
+ import redis.asyncio as redis
665
+
666
+ redis_client = redis.from_url("redis://localhost:6379/0")
667
+ lock_provider = RedisLockProvider(redis_client)
668
+
669
+ scheduler = DynamicScheduler(
670
+ repository=repo,
671
+ enqueuer=enqueuer,
672
+ lock_provider=lock_provider
673
+ )
674
+ ```
675
+
676
+ #### Usando PostgreSQL (asyncpg)
677
+ Si usas Procrastinate o bases de datos SQL y no quieres levantar Redis:
566
678
 
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)
679
+ ```python
680
+ from hexcore.infrastructure.cqrs.postgres_lock import PostgresLockProvider
571
681
 
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)
682
+ lock_provider = PostgresLockProvider(my_asyncpg_pool)
683
+ await lock_provider.setup() # Crea la tabla de locks si no existe
684
+
685
+ scheduler = DynamicScheduler(
686
+ repository=repo,
687
+ enqueuer=enqueuer,
688
+ lock_provider=lock_provider
689
+ )
576
690
  ```
577
691
 
578
692
  ---
@@ -1,29 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: hexcore
3
- Version: 2.3.0
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
- Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
6
- License-Expression: MIT
7
- Requires-Python: >=3.12
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: aiosqlite>=0.21.0
11
- Requires-Dist: alembic>=1.16.5
12
- Requires-Dist: asyncpg>=0.30.0
13
- Requires-Dist: beanie>=2.0.0
14
- Requires-Dist: fastapi>=0.116.1
15
- Requires-Dist: pika>=1.3.2
16
- Requires-Dist: pydantic>=2.11.7
17
- Requires-Dist: redis>=6.4.0
18
- Requires-Dist: sqlalchemy>=2.0.43
19
- Requires-Dist: typer>=0.17.3
20
- Requires-Dist: ruff>=0.12.11
21
- Provides-Extra: procrastinate
22
- Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
23
- Provides-Extra: rabbitmq
24
- Requires-Dist: aio-pika>=9.4.0; extra == "rabbitmq"
25
- Dynamic: license-file
26
-
27
1
  # 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)
28
2
  HexCore es un módulo base reutilizable para proyectos Python que implementan arquitectura hexagonal y event handling.
29
3
 
@@ -516,23 +490,39 @@ class ProcrastinateEnqueuer(ITaskEnqueuer):
516
490
  await process_generic_task.defer_async(task_name=task_name, payload=payload)
517
491
  ```
518
492
 
519
- #### 3. Configurar tus Buses (Enrutamiento Automático)
493
+ #### 2. Configurar tus Buses con un Adaptador Oficial
520
494
 
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).
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.
522
496
 
523
- ```python
524
- from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus, InMemoryEventBus
497
+ Si además deseas persistencia o distribución de Eventos entre múltiples workers/servidores (Pub/Sub), puedes cambiar el `InMemoryEventBus` por `RedisEventBus`, `PostgresEventBus` o `RabbitMQEventBus`:
525
498
 
526
- enqueuer = ProcrastinateEnqueuer()
499
+ ```python
500
+ from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus
501
+ from hexcore.infrastructure.task_queues.celery_adapter import CeleryEnqueuer
502
+ from hexcore.infrastructure.cqrs.redis_bus import RedisEventBus
503
+ from celery import Celery
504
+ import redis.asyncio as redis
505
+
506
+ # 1. Adaptador de Task Queue (Para Comandos asíncronos y Event Handlers asíncronos)
507
+ app = Celery("my_app", broker="redis://localhost:6379/0")
508
+ enqueuer = CeleryEnqueuer(app)
527
509
  serializer = PydanticSerializer()
528
510
 
529
- # El bus evalúa: ¿Tiene el comando @background_command? Si es así, usa el enqueuer.
530
511
  command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
531
512
 
532
- # El bus evalúa suscriptores: ¿Tienen @background_handler? Si es así, usa el enqueuer.
533
- event_bus = InMemoryEventBus(enqueuer=enqueuer, serializer=serializer)
513
+ # 2. Event Bus (Para enviar los Eventos por la red)
514
+ redis_client = redis.from_url("redis://localhost:6379/0")
515
+ event_bus = RedisEventBus(
516
+ redis_client=redis_client,
517
+ serializer=serializer,
518
+ stream_name="hexcore:events",
519
+ group_name="api_workers",
520
+ enqueuer=enqueuer # <-- Importante para inyectarle la habilidad de Smart Routing
521
+ )
534
522
  ```
535
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
+
536
526
  #### 4. Ejecutar tareas genéricas
537
527
 
538
528
  Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
@@ -548,10 +538,11 @@ await enqueuer.enqueue_task(
548
538
 
549
539
  #### 5. Levantar el Worker (Consumidor Universal)
550
540
 
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.
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:
552
542
 
553
543
  ```python
554
544
  from hexcore.infrastructure.workers.consumer import CQRSConsumer
545
+ from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
555
546
 
556
547
  consumer = CQRSConsumer(
557
548
  command_bus=command_bus, # Tu CommandBus configurado
@@ -559,20 +550,98 @@ consumer = CQRSConsumer(
559
550
  serializer=serializer
560
551
  )
561
552
 
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)
553
+ # ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
554
+ register_hexcore_celery_tasks(app, consumer)
555
+ ```
556
+
557
+ ---
558
+
559
+ ## Tareas Periódicas Dinámicas (Cronjobs en Caliente)
560
+
561
+ HexCore incluye un **`DynamicScheduler`** que te permite programar tareas (`@background_task`) para que se ejecuten periódicamente. La ventaja clave es que lee la configuración desde un repositorio (como tu Base de Datos), permitiendo activar, desactivar o cambiar los horarios **sin necesidad de reiniciar tus servidores**.
566
562
 
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)
563
+ ### 1. Implementa tu Repositorio
564
+ Implementa `ICronJobRepository` para decirle al Scheduler de dónde leer la configuración (ej. usando SQLAlchemy, MongoDB o Redis):
571
565
 
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)
566
+ ```python
567
+ from hexcore.domain.cqrs.cron import ICronJobRepository, CronJobDefinition
568
+ from datetime import datetime
569
+
570
+ class MiCronRepository(ICronJobRepository):
571
+ async def get_active_jobs(self) -> list[CronJobDefinition]:
572
+ # SELECT * FROM cronjobs WHERE is_active = true
573
+ return [
574
+ CronJobDefinition(
575
+ job_id="1",
576
+ task_name="hexcore.process_task", # o cualquier @background_task
577
+ cron_expression="*/5 * * * *", # cada 5 minutos
578
+ payload={"task_name": "clean_db", "payload": {}}
579
+ )
580
+ ]
581
+
582
+ async def update_last_run(self, job_id: str, run_time: datetime) -> None:
583
+ # UPDATE cronjobs SET last_run = run_time WHERE id = job_id
584
+ pass
585
+ ```
586
+
587
+ ### 2. Levanta el Scheduler
588
+ En un proceso en background de tu API o en un microservicio separado, arranca el Scheduler inyectándole tu Enqueuer favorito (Celery, Procrastinate):
589
+
590
+ ```python
591
+ from hexcore.application.cqrs.scheduler import DynamicScheduler
592
+ import asyncio
593
+
594
+ async def run_scheduler():
595
+ repo = MiCronRepository()
596
+ scheduler = DynamicScheduler(
597
+ repository=repo,
598
+ enqueuer=enqueuer,
599
+ tick_interval_seconds=60
600
+ )
601
+
602
+ await scheduler.start() # Bucle infinito
603
+
604
+ # En FastAPI puedes usar lifespan para lanzarlo:
605
+ # asyncio.create_task(run_scheduler())
606
+ ```
607
+
608
+ El Scheduler evaluará las expresiones (gracias a `croniter`) y delegará la carga pesada al enqueuer. ¡Tu Worker no necesita saber de horarios, solo ejecuta las tareas cuando le llegan!
609
+
610
+ ### 3. Distributed Locks (Evitar ejecuciones dobles)
611
+
612
+ Si corres tu aplicación en múltiples contenedores o réplicas (ej. Kubernetes), podrías tener múltiples instancias del `DynamicScheduler` ejecutándose al mismo tiempo. Para evitar que el mismo cronjob se encole dos veces en el mismo minuto, HexCore soporta **Locks Distribuidos**.
613
+
614
+ Puedes inyectar un proveedor de locks (`ILockProvider`) usando Redis o PostgreSQL (si lo usas como tu DB). Al inyectarlo, el Scheduler bloqueará atómicamente la tarea a través de toda tu red.
615
+
616
+ #### Usando Redis
617
+ ```python
618
+ from hexcore.infrastructure.cqrs.redis_lock import RedisLockProvider
619
+ import redis.asyncio as redis
620
+
621
+ redis_client = redis.from_url("redis://localhost:6379/0")
622
+ lock_provider = RedisLockProvider(redis_client)
623
+
624
+ scheduler = DynamicScheduler(
625
+ repository=repo,
626
+ enqueuer=enqueuer,
627
+ lock_provider=lock_provider
628
+ )
629
+ ```
630
+
631
+ #### Usando PostgreSQL (asyncpg)
632
+ Si usas Procrastinate o bases de datos SQL y no quieres levantar Redis:
633
+
634
+ ```python
635
+ from hexcore.infrastructure.cqrs.postgres_lock import PostgresLockProvider
636
+
637
+ lock_provider = PostgresLockProvider(my_asyncpg_pool)
638
+ await lock_provider.setup() # Crea la tabla de locks si no existe
639
+
640
+ scheduler = DynamicScheduler(
641
+ repository=repo,
642
+ enqueuer=enqueuer,
643
+ lock_provider=lock_provider
644
+ )
576
645
  ```
577
646
 
578
647
  ---
@@ -9,6 +9,7 @@ from .pipeline import MiddlewarePipeline
9
9
  from .in_memory_buses import InMemoryCommandBus, InMemoryQueryBus, InMemoryEventBus
10
10
  from .adapters import UseCaseCommandHandler
11
11
  from .factory import CQRSFactory
12
+ from .scheduler import DynamicScheduler
12
13
 
13
14
  __all__ = [
14
15
  "HandlerRegistry",
@@ -21,4 +22,5 @@ __all__ = [
21
22
  "InMemoryEventBus",
22
23
  "UseCaseCommandHandler",
23
24
  "CQRSFactory",
25
+ "DynamicScheduler",
24
26
  ]
@@ -0,0 +1,99 @@
1
+ """
2
+ Servicio encargado de evaluar y encolar tareas periódicas.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ from datetime import datetime, timezone
9
+
10
+ from hexcore.domain.cqrs.cron import ICronJobRepository, ILockProvider
11
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class DynamicScheduler:
17
+ """
18
+ Evalúa constantemente un repositorio de tareas periódicas y delega la ejecución
19
+ al TaskEnqueuer (Celery/Procrastinate). Permite cambiar configuraciones cron en "caliente".
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ repository: ICronJobRepository,
25
+ enqueuer: ITaskEnqueuer,
26
+ lock_provider: ILockProvider | None = None,
27
+ tick_interval_seconds: int = 30
28
+ ) -> None:
29
+ self.repository = repository
30
+ self.enqueuer = enqueuer
31
+ self.lock_provider = lock_provider
32
+ self.tick_interval_seconds = tick_interval_seconds
33
+
34
+ self._stop_event = asyncio.Event()
35
+
36
+ async def start(self) -> None:
37
+ """Inicia el bucle infinito del scheduler."""
38
+ import croniter
39
+
40
+ logger.info(f"[*] DynamicScheduler started (Tick interval: {self.tick_interval_seconds}s)")
41
+ self._stop_event.clear()
42
+
43
+ # El estado base es el momento en que se levantó el scheduler.
44
+ last_check_time = datetime.now(timezone.utc)
45
+
46
+ while not self._stop_event.is_set():
47
+ try:
48
+ # 1. Esperamos el tiempo del tick
49
+ await asyncio.sleep(self.tick_interval_seconds)
50
+
51
+ current_time = datetime.now(timezone.utc)
52
+
53
+ # 2. Obtenemos la configuración fresca de BD
54
+ active_jobs = await self.repository.get_active_jobs()
55
+
56
+ for job in active_jobs:
57
+ # Determinamos desde cuándo chequear. Si el job tiene `last_run_at`, lo usamos.
58
+ # Sino, usamos el `last_check_time` global del bucle.
59
+ base_time = job.last_run_at or last_check_time
60
+
61
+ try:
62
+ # 3. Comprobamos si la expresión cron cayó entre base_time y current_time
63
+ if croniter.croniter.match(job.cron_expression, current_time):
64
+ # Evitar múltiples encolados en el mismo tick (minuto)
65
+ # Intentar adquirir lock si hay proveedor (Distributed Locks)
66
+ if self.lock_provider:
67
+ # Usamos el minuto actual (truncado) para evitar colisiones distribuidas
68
+ minute_key = current_time.replace(second=0, microsecond=0).isoformat()
69
+ lock_key = f"hexcore:cron_lock:{job.job_id}:{minute_key}"
70
+
71
+ lock_acquired = await self.lock_provider.acquire_lock(lock_key, ttl_seconds=60)
72
+ if not lock_acquired:
73
+ logger.debug(f"CronJob '{job.job_id}' ignorado (lock adquirido por otra réplica).")
74
+ continue
75
+
76
+ logger.info(f"Encolando CronJob '{job.job_id}' -> Task: {job.task_name}")
77
+
78
+ await self.enqueuer.enqueue_task(
79
+ task_name=job.task_name,
80
+ payload=job.payload,
81
+ queue=job.queue
82
+ )
83
+
84
+ # Actualizamos el timestamp en el repositorio
85
+ await self.repository.update_last_run(job.job_id, current_time)
86
+
87
+ except Exception as e:
88
+ logger.error(f"Error procesando el cron job {job.job_id}: {e}")
89
+
90
+ last_check_time = current_time
91
+
92
+ except asyncio.CancelledError:
93
+ break
94
+ except Exception as e:
95
+ logger.error(f"Error crítico en el bucle del scheduler: {e}")
96
+
97
+ def stop(self) -> None:
98
+ """Detiene el bucle del scheduler de forma segura."""
99
+ self._stop_event.set()
@@ -21,6 +21,7 @@ from .handlers import ICommandHandler, IQueryHandler
21
21
  from .buses import ICommandBus, IQueryBus, IEventBus
22
22
  from .middleware import IMiddleware
23
23
  from .serializer import ISerializer
24
+ from .cron import CronJobDefinition, ICronJobRepository, ILockProvider
24
25
 
25
26
  __all__ = [
26
27
  # Nombres canónicos (Abstract*)
@@ -34,6 +35,10 @@ __all__ = [
34
35
  "AbstractMiddleware",
35
36
  "NextHandler",
36
37
  "AbstractSerializer",
38
+ # Cron
39
+ "CronJobDefinition",
40
+ "ICronJobRepository",
41
+ "ILockProvider",
37
42
  # Aliases (I*)
38
43
  "ICommandHandler",
39
44
  "IQueryHandler",
@@ -0,0 +1,69 @@
1
+ """
2
+ Definiciones de Dominio para el agendamiento dinámico de Tareas Periódicas (Cronjobs).
3
+ """
4
+ import abc
5
+ import typing as t
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+
9
+
10
+ @dataclass
11
+ class CronJobDefinition:
12
+ """
13
+ Representa la definición de una tarea periódica que debe ejecutarse.
14
+ Esta configuración normalmente se guarda en una BD para permitir
15
+ cambios "en caliente" (hot reload).
16
+ """
17
+ job_id: str
18
+ task_name: str
19
+ cron_expression: str
20
+ payload: dict[str, t.Any] = field(default_factory=dict)
21
+ queue: str = "default"
22
+ is_active: bool = True
23
+
24
+ # Para control de estado si se usa sin locks distribuidos
25
+ last_run_at: datetime | None = None
26
+
27
+
28
+ class ICronJobRepository(abc.ABC):
29
+ """
30
+ Repositorio que el usuario debe implementar (ej. usando SQLAlchemy, Beanie, etc.)
31
+ para proveer al DynamicScheduler la lista actualizada de tareas a ejecutar.
32
+ """
33
+
34
+ @abc.abstractmethod
35
+ async def get_active_jobs(self) -> list[CronJobDefinition]:
36
+ """
37
+ Retorna la lista de trabajos activos.
38
+ """
39
+ pass
40
+
41
+ @abc.abstractmethod
42
+ async def update_last_run(self, job_id: str, run_time: datetime) -> None:
43
+ """
44
+ Actualiza la fecha de última ejecución de un job para evitar
45
+ que se ejecute repetidamente en el mismo tick.
46
+ """
47
+ pass
48
+
49
+
50
+ class ILockProvider(abc.ABC):
51
+ """
52
+ Proveedor de Locks Distribuidos (ej. Redis, PostgreSQL Advisory Locks).
53
+ Utilizado por el DynamicScheduler para garantizar que múltiples réplicas
54
+ del scheduler no ejecuten el mismo cronjob de manera duplicada.
55
+ """
56
+
57
+ @abc.abstractmethod
58
+ async def acquire_lock(self, lock_key: str, ttl_seconds: int) -> bool:
59
+ """
60
+ Intenta adquirir un lock. Retorna True si lo consiguió, False si ya estaba tomado.
61
+ """
62
+ pass
63
+
64
+ @abc.abstractmethod
65
+ async def release_lock(self, lock_key: str) -> None:
66
+ """
67
+ Libera el lock explícitamente (opcional, ya que el TTL lo hace automáticamente).
68
+ """
69
+ pass