hexcore 6.0.0__tar.gz → 6.0.2__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 (163) hide show
  1. {hexcore-6.0.0/hexcore.egg-info → hexcore-6.0.2}/PKG-INFO +42 -3
  2. {hexcore-6.0.0 → hexcore-6.0.2}/README.md +41 -2
  3. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/_deprecation.py +9 -2
  4. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/cron.py +10 -1
  5. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/routing.py +40 -4
  6. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/cron_sql.py +55 -9
  7. {hexcore-6.0.0 → hexcore-6.0.2/hexcore.egg-info}/PKG-INFO +42 -3
  8. {hexcore-6.0.0 → hexcore-6.0.2}/pyproject.toml +1 -1
  9. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_middlewares_and_routing.py +68 -0
  10. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_cron_sql.py +106 -0
  11. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_deprecations.py +60 -1
  12. {hexcore-6.0.0 → hexcore-6.0.2}/LICENSE +0 -0
  13. {hexcore-6.0.0 → hexcore-6.0.2}/MANIFEST.in +0 -0
  14. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/__init__.py +0 -0
  15. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/__main__.py +0 -0
  16. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/__init__.py +0 -0
  17. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/__init__.py +0 -0
  18. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/adapters.py +0 -0
  19. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/config.py +0 -0
  20. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/factory.py +0 -0
  21. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/in_memory_buses.py +0 -0
  22. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/pipeline.py +0 -0
  23. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/registry.py +0 -0
  24. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/scheduler.py +0 -0
  25. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/__init__.py +0 -0
  26. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/base.py +0 -0
  27. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/cursor.py +0 -0
  28. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/errors.py +0 -0
  29. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/query.py +0 -0
  30. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/use_cases/__init__.py +0 -0
  31. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/use_cases/base.py +0 -0
  32. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/use_cases/query.py +0 -0
  33. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/config.py +0 -0
  34. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/cqrs.py +0 -0
  35. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/__init__.py +0 -0
  36. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/auth/__init__.py +0 -0
  37. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/auth/permissions.py +0 -0
  38. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/auth/value_objects.py +0 -0
  39. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/base.py +0 -0
  40. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/__init__.py +0 -0
  41. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/buses.py +0 -0
  42. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/commands.py +0 -0
  43. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/context.py +0 -0
  44. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/decorators.py +0 -0
  45. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/exceptions.py +0 -0
  46. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/handlers.py +0 -0
  47. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/middleware.py +0 -0
  48. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/queries.py +0 -0
  49. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/resolution.py +0 -0
  50. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/serializer.py +0 -0
  51. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/task_queues.py +0 -0
  52. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/events.py +0 -0
  53. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/exceptions.py +0 -0
  54. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/repositories.py +0 -0
  55. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/services.py +0 -0
  56. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/uow.py +0 -0
  57. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/fastapi.py +0 -0
  58. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/__init__.py +0 -0
  59. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/__init__.py +0 -0
  60. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/app.py +0 -0
  61. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/cqrs.py +0 -0
  62. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/exception_handlers.py +0 -0
  63. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/health.py +0 -0
  64. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/lifespan.py +0 -0
  65. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/middlewares.py +0 -0
  66. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/rate_limit.py +0 -0
  67. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/streaming.py +0 -0
  68. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/utils.py +0 -0
  69. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cache/__init__.py +0 -0
  70. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  71. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  72. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  73. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cli.py +0 -0
  74. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  75. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
  76. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
  77. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/postgres_lock.py +0 -0
  78. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  79. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  80. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  81. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
  82. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/redis_lock.py +0 -0
  83. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/events/__init__.py +0 -0
  84. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  85. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  86. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/__init__.py +0 -0
  87. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/base.py +0 -0
  88. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/decorators.py +0 -0
  89. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/implementations.py +0 -0
  90. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  91. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  92. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  93. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  94. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  95. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  96. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/utils.py +0 -0
  97. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/task_queues/__init__.py +0 -0
  98. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/task_queues/celery_adapter.py +0 -0
  99. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
  100. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/uow/__init__.py +0 -0
  101. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/uow/decorators.py +0 -0
  102. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/uow/helpers.py +0 -0
  103. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/uow/scopes.py +0 -0
  104. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/workers/__init__.py +0 -0
  105. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/workers/consumer.py +0 -0
  106. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
  107. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/workers/runner.py +0 -0
  108. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/py.typed +0 -0
  109. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/sql.py +0 -0
  110. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/testing/__init__.py +0 -0
  111. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/testing/fakes.py +0 -0
  112. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/testing/fixtures.py +0 -0
  113. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/testing/helpers.py +0 -0
  114. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/types.py +0 -0
  115. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/SOURCES.txt +0 -0
  116. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/dependency_links.txt +0 -0
  117. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/entry_points.txt +0 -0
  118. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/requires.txt +0 -0
  119. {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/top_level.txt +0 -0
  120. {hexcore-6.0.0 → hexcore-6.0.2}/scripts/__init__.py +0 -0
  121. {hexcore-6.0.0 → hexcore-6.0.2}/scripts/main.py +0 -0
  122. {hexcore-6.0.0 → hexcore-6.0.2}/setup.cfg +0 -0
  123. {hexcore-6.0.0 → hexcore-6.0.2}/tests/conftest.py +0 -0
  124. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_app_and_lifespan.py +0 -0
  125. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_cqrs_providers.py +0 -0
  126. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_exception_handlers.py +0 -0
  127. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_health.py +0 -0
  128. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_rate_limit.py +0 -0
  129. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_streaming.py +0 -0
  130. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_basic.py +0 -0
  131. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_beanie_query_utils.py +0 -0
  132. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_celery_event_loop.py +0 -0
  133. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_config_loading.py +0 -0
  134. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_consumer_optional_event_bus.py +0 -0
  135. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_cqrs.py +0 -0
  136. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_cqrs_factory.py +0 -0
  137. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_cqrs_middlewares.py +0 -0
  138. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_documentation_examples.py +0 -0
  139. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_domain_service_query.py +0 -0
  140. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_dotted_resolution.py +0 -0
  141. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_facades.py +0 -0
  142. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_handler_registry.py +0 -0
  143. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_infrastructure_query_path.py +0 -0
  144. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_lock_error_policy.py +0 -0
  145. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_optional_dependencies.py +0 -0
  146. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_postgres_bus.py +0 -0
  147. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_postgres_lock.py +0 -0
  148. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_query_cursor_and_endpoint.py +0 -0
  149. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_query_field_validation.py +0 -0
  150. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_rabbitmq_bus.py +0 -0
  151. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_redis_bus.py +0 -0
  152. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_redis_lock.py +0 -0
  153. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_repositories_utils.py +0 -0
  154. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_scheduler.py +0 -0
  155. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_scheduler_catchup.py +0 -0
  156. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_smart_routing.py +0 -0
  157. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_sql_session_layer.py +0 -0
  158. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_task_queues_adapters.py +0 -0
  159. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_testing_utilities.py +0 -0
  160. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_uow_session_regression.py +0 -0
  161. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_use_cases_query.py +0 -0
  162. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_worker_bus_integration.py +0 -0
  163. {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_worker_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 6.0.0
3
+ Version: 6.0.2
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
@@ -385,6 +385,18 @@ admin = build_root_router(
385
385
  mount_routers(app, [admin, (public_router, {"prefix": "/v1"})])
386
386
  ```
387
387
 
388
+ `children` acepta también una **secuencia**, y no es azúcar: un dict no puede tener dos claves
389
+ `""`, así que un raíz con varios hijos que **ya traen su propio prefijo** —lo normal cuando cada
390
+ feature declara sus rutas completas— no se puede expresar con un mapa.
391
+
392
+ ```python
393
+ # usuarios_router y tickets_router ya son APIRouter(prefix="/usuarios") y (prefix="/tickets").
394
+ api_v1 = build_root_router("/api/v1", [usuarios_router, tickets_router])
395
+
396
+ # Se pueden mezclar: un router pelado equivale a ("", router).
397
+ api_v1 = build_root_router("/api/v1", [usuarios_router, ("/reports", reports_router)])
398
+ ```
399
+
388
400
  ### Endpoints de listado y búsqueda
389
401
 
390
402
  ```python
@@ -706,6 +718,28 @@ otro origen (Mongo, Redis, un YAML), implementá `cqrs.ICronJobRepository`.
706
718
  `cron_job()` deriva el `task_name` de `__cqrs_task_name__`: escribirlo a mano es cómo se acaba
707
719
  con un cron que encola una tarea ya renombrada, y el fallo aparece en el worker.
708
720
 
721
+ Cada job lleva además una `description` que viaja a la tabla. El scheduler no la usa: es lo que
722
+ un panel le muestra al operador para que sepa **qué hace** un cron antes de desactivarlo — con
723
+ sólo `task_name` y una expresión cron no se distingue apagar algo inofensivo de dejar de
724
+ facturar. Por defecto sale de la primera línea del docstring de la tarea, que suele ser
725
+ exactamente eso:
726
+
727
+ ```python
728
+ @cqrs.background_task(queue="billing")
729
+ async def emitir_facturas() -> None:
730
+ """Emite las facturas del mes y las envía por email."""
731
+ ...
732
+
733
+ cqrs.cron_job(emitir_facturas, "0 6 1 * *")
734
+ # description == "Emite las facturas del mes y las envía por email."
735
+
736
+ cqrs.cron_job(cerrar_caja, "0 3 * * *", description="Cierra la caja del día.")
737
+ ```
738
+
739
+ > Si tu tabla es anterior a esta columna, la migración es un `add_column` nullable —
740
+ > `create_cron_tables()` lo trae escrito en su docstring. Un modelo propio que no la tenga no
741
+ > rompe: el seed inserta lo que la tabla admita.
742
+
709
743
  **Cómo decide si toca ejecutar.** No compara contra el minuto actual, sino que busca si hubo
710
744
  alguna ocurrencia entre `last_run_at` y ahora:
711
745
 
@@ -884,18 +918,23 @@ estructura de migraciones con Alembic.
884
918
 
885
919
  | Serie | Estado | Qué significa |
886
920
  | :-- | :-- | :-- |
887
- | **5.x** | ✅ **Activa** | La única soportada. Recibe features y correcciones. |
921
+ | **6.x** | ✅ **Activa** | La única soportada. Recibe features y correcciones. |
922
+ | **5.x** | ⛔ **Deprecada** | Misma superficie de API que 6.x. Migrar es actualizar la versión. |
888
923
  | **4.x** | ⛔ **Deprecada** | Aplicación **parcial**: le faltan el fix del event loop de Celery, las fachadas y la documentación alineada. |
889
924
  | **3.x** | ⛔ **Deprecada** | Aplicación **parcial**: tiene las correcciones P0/P1 pero ninguna de las factories de FastAPI. |
890
925
  | **2.x** | ⛔ **Deprecada** | Contiene los bugs silenciosos corregidos en 5.x (ver abajo). |
891
926
  | **1.x** | ⛔ **Deprecada** | Sin soporte de ningún tipo. |
892
927
 
893
- **Todo lo anterior a 5.0 está deprecado. Migrá a 5.x.**
928
+ **Todo lo anterior a 5.0 está deprecado. Migrá a 6.x.**
894
929
 
895
930
  3.0.0 y 4.0.0 existen sólo porque el trabajo se mergeó por fases y cada merge disparó un bump
896
931
  automático: **no son releases pensadas para usarse**, son cortes intermedios de la misma
897
932
  migración. 5.0.0 es la primera versión completa.
898
933
 
934
+ 6.0.0 es el mismo caso: la disparó un PR de documentación con un commit `feat!:`. No hay
935
+ **ninguna** ruptura de API entre 5.x y 6.x — los alias anteriores a 5.0 siguen importables y
936
+ siguen avisando.
937
+
899
938
  ### Por qué 2.x y anteriores no deberían estar en producción
900
939
 
901
940
  No es una cuestión de estilo: 2.x tiene defectos que no lanzan excepción y no aparecen en logs
@@ -340,6 +340,18 @@ admin = build_root_router(
340
340
  mount_routers(app, [admin, (public_router, {"prefix": "/v1"})])
341
341
  ```
342
342
 
343
+ `children` acepta también una **secuencia**, y no es azúcar: un dict no puede tener dos claves
344
+ `""`, así que un raíz con varios hijos que **ya traen su propio prefijo** —lo normal cuando cada
345
+ feature declara sus rutas completas— no se puede expresar con un mapa.
346
+
347
+ ```python
348
+ # usuarios_router y tickets_router ya son APIRouter(prefix="/usuarios") y (prefix="/tickets").
349
+ api_v1 = build_root_router("/api/v1", [usuarios_router, tickets_router])
350
+
351
+ # Se pueden mezclar: un router pelado equivale a ("", router).
352
+ api_v1 = build_root_router("/api/v1", [usuarios_router, ("/reports", reports_router)])
353
+ ```
354
+
343
355
  ### Endpoints de listado y búsqueda
344
356
 
345
357
  ```python
@@ -661,6 +673,28 @@ otro origen (Mongo, Redis, un YAML), implementá `cqrs.ICronJobRepository`.
661
673
  `cron_job()` deriva el `task_name` de `__cqrs_task_name__`: escribirlo a mano es cómo se acaba
662
674
  con un cron que encola una tarea ya renombrada, y el fallo aparece en el worker.
663
675
 
676
+ Cada job lleva además una `description` que viaja a la tabla. El scheduler no la usa: es lo que
677
+ un panel le muestra al operador para que sepa **qué hace** un cron antes de desactivarlo — con
678
+ sólo `task_name` y una expresión cron no se distingue apagar algo inofensivo de dejar de
679
+ facturar. Por defecto sale de la primera línea del docstring de la tarea, que suele ser
680
+ exactamente eso:
681
+
682
+ ```python
683
+ @cqrs.background_task(queue="billing")
684
+ async def emitir_facturas() -> None:
685
+ """Emite las facturas del mes y las envía por email."""
686
+ ...
687
+
688
+ cqrs.cron_job(emitir_facturas, "0 6 1 * *")
689
+ # description == "Emite las facturas del mes y las envía por email."
690
+
691
+ cqrs.cron_job(cerrar_caja, "0 3 * * *", description="Cierra la caja del día.")
692
+ ```
693
+
694
+ > Si tu tabla es anterior a esta columna, la migración es un `add_column` nullable —
695
+ > `create_cron_tables()` lo trae escrito en su docstring. Un modelo propio que no la tenga no
696
+ > rompe: el seed inserta lo que la tabla admita.
697
+
664
698
  **Cómo decide si toca ejecutar.** No compara contra el minuto actual, sino que busca si hubo
665
699
  alguna ocurrencia entre `last_run_at` y ahora:
666
700
 
@@ -839,18 +873,23 @@ estructura de migraciones con Alembic.
839
873
 
840
874
  | Serie | Estado | Qué significa |
841
875
  | :-- | :-- | :-- |
842
- | **5.x** | ✅ **Activa** | La única soportada. Recibe features y correcciones. |
876
+ | **6.x** | ✅ **Activa** | La única soportada. Recibe features y correcciones. |
877
+ | **5.x** | ⛔ **Deprecada** | Misma superficie de API que 6.x. Migrar es actualizar la versión. |
843
878
  | **4.x** | ⛔ **Deprecada** | Aplicación **parcial**: le faltan el fix del event loop de Celery, las fachadas y la documentación alineada. |
844
879
  | **3.x** | ⛔ **Deprecada** | Aplicación **parcial**: tiene las correcciones P0/P1 pero ninguna de las factories de FastAPI. |
845
880
  | **2.x** | ⛔ **Deprecada** | Contiene los bugs silenciosos corregidos en 5.x (ver abajo). |
846
881
  | **1.x** | ⛔ **Deprecada** | Sin soporte de ningún tipo. |
847
882
 
848
- **Todo lo anterior a 5.0 está deprecado. Migrá a 5.x.**
883
+ **Todo lo anterior a 5.0 está deprecado. Migrá a 6.x.**
849
884
 
850
885
  3.0.0 y 4.0.0 existen sólo porque el trabajo se mergeó por fases y cada merge disparó un bump
851
886
  automático: **no son releases pensadas para usarse**, son cortes intermedios de la misma
852
887
  migración. 5.0.0 es la primera versión completa.
853
888
 
889
+ 6.0.0 es el mismo caso: la disparó un PR de documentación con un commit `feat!:`. No hay
890
+ **ninguna** ruptura de API entre 5.x y 6.x — los alias anteriores a 5.0 siguen importables y
891
+ siguen avisando.
892
+
854
893
  ### Por qué 2.x y anteriores no deberían estar en producción
855
894
 
856
895
  No es una cuestión de estilo: 2.x tiene defectos que no lanzan excepción y no aparecen en logs
@@ -3,7 +3,7 @@ Mecanismo de deprecación de la superficie de API anterior a 5.0.
3
3
 
4
4
  Conviven dos nombres para varios conceptos (`AbstractCommandBus`/`ICommandBus`,
5
5
  `AbstractSerializer`/`ISerializer`, …) por retrocompatibilidad con 1.x/2.x. Los canónicos
6
- son los `Abstract*`; los alias siguen funcionando, pero avisan y se eliminarán en 6.0.
6
+ son los `Abstract*`; los alias siguen funcionando, pero avisan y se eliminarán en 7.0.
7
7
 
8
8
  Un alias declarado como `ICommandBus = AbstractCommandBus` **no puede avisar**: es una
9
9
  asignación, y leerlo no ejecuta nada. Por eso el aviso se implementa con `__getattr__` de
@@ -34,7 +34,14 @@ __all__ = [
34
34
 
35
35
  #: Versión en la que se eliminan los alias. Se menciona en cada aviso para que el usuario
36
36
  #: sepa cuánto margen tiene.
37
- REMOVED_IN = "6.0"
37
+ #:
38
+ #: **Tiene que ser estrictamente mayor que la versión publicada**, o el aviso se contradice
39
+ #: a sí mismo: anunciar "se elimina en 6.0" corriendo en 6.0.0, con los alias delante, le
40
+ #: enseña al usuario que estos avisos no hay que leerlos. Ya pasó, porque en este repo dos
41
+ #: majors salieron de commits `feat!:` involuntarios. Lo vigila
42
+ #: `test_removed_in_is_ahead_of_the_published_version`: si un bump vuelve a alcanzar a este
43
+ #: valor, el fallo salta en CI y no en el aviso que lee el usuario.
44
+ REMOVED_IN = "7.0"
38
45
 
39
46
 
40
47
  def warn_deprecated(
@@ -20,10 +20,19 @@ class CronJobDefinition:
20
20
  payload: dict[str, t.Any] = field(default_factory=dict)
21
21
  queue: str = "default"
22
22
  is_active: bool = True
23
-
23
+
24
24
  # Para control de estado si se usa sin locks distribuidos
25
25
  last_run_at: datetime | None = None
26
26
 
27
+ # Qué hace el job, en prosa. No lo usa el scheduler: existe para el humano que ve la
28
+ # tabla en un panel de administración y tiene que decidir si desactivar un cron. Sin
29
+ # esto sólo se ve `task_name` y una expresión cron, que no dicen si apagarlo es
30
+ # inofensivo o deja de facturar.
31
+ #
32
+ # Va al final del dataclass a propósito: intercalarlo cambiaría el significado de los
33
+ # argumentos posicionales de quien ya construye definiciones a mano.
34
+ description: str | None = None
35
+
27
36
 
28
37
  class ICronJobRepository(abc.ABC):
29
38
  """
@@ -17,10 +17,35 @@ __all__ = ["build_root_router", "mount_routers"]
17
17
  # (`prefix`, `tags`, `dependencies`…).
18
18
  MountableRouter = t.Union[APIRouter, t.Tuple[APIRouter, t.Dict[str, t.Any]]]
19
19
 
20
+ # Los hijos de un router raíz. El `Mapping` es la forma bonita cuando cada hijo tiene su
21
+ # sub-prefijo; la secuencia existe porque un dict **no puede** tener dos claves `""`, y un
22
+ # raíz cuyos hijos ya declaran sus rutas completas necesita exactamente eso.
23
+ RouterChildren = t.Union[
24
+ t.Mapping[str, APIRouter],
25
+ t.Sequence[t.Union[APIRouter, t.Tuple[str, APIRouter]]],
26
+ ]
27
+
28
+
29
+ def _iter_children(
30
+ children: RouterChildren,
31
+ ) -> t.Iterator[t.Tuple[str, APIRouter]]:
32
+ """Normaliza las dos formas de `children` a pares ``(sub_prefijo, router)``."""
33
+ if isinstance(children, t.Mapping):
34
+ yield from children.items()
35
+ return
36
+
37
+ for entry in children:
38
+ if isinstance(entry, tuple):
39
+ yield entry
40
+ else:
41
+ # Un router pelado en la secuencia = sin sub-prefijo. Es el caso que motivó
42
+ # aceptar secuencias, así que merece no tener que escribir `("", router)`.
43
+ yield "", entry
44
+
20
45
 
21
46
  def build_root_router(
22
47
  prefix: str,
23
- children: t.Mapping[str, APIRouter],
48
+ children: RouterChildren,
24
49
  *,
25
50
  dependencies: t.Sequence[t.Any] = (),
26
51
  tags: list[str] | None = None,
@@ -31,8 +56,16 @@ def build_root_router(
31
56
 
32
57
  Args:
33
58
  prefix: Prefijo del router raíz (p. ej. ``"/admin"``).
34
- children: Mapa ``{sub_prefijo: router}``. Un sub-prefijo vacío monta el hijo
35
- directamente sobre el prefijo raíz.
59
+ children: Los hijos, en cualquiera de las dos formas:
60
+
61
+ - **Mapa** ``{sub_prefijo: router}``. Un sub-prefijo vacío monta el hijo
62
+ directamente sobre el prefijo raíz.
63
+ - **Secuencia** de routers o de pares ``(sub_prefijo, router)``. Un router
64
+ pelado equivale a sub-prefijo vacío.
65
+
66
+ La secuencia no es azúcar: un dict no puede tener **dos** claves ``""``, así
67
+ que un raíz con varios hijos que ya traen su propio prefijo —lo normal cuando
68
+ cada feature declara sus rutas completas— no se puede expresar con un mapa.
36
69
  dependencies: Dependencias comunes a todos los hijos (típicamente la auth).
37
70
  tags: Tags comunes.
38
71
  **router_kwargs: Se pasan tal cual a `APIRouter` (``responses``,
@@ -49,6 +82,9 @@ def build_root_router(
49
82
  dependencies=[Depends(require_admin)],
50
83
  tags=["admin"],
51
84
  )
85
+
86
+ # Hijos que ya declaran sus rutas completas: no hay sub-prefijo que poner.
87
+ api_router = build_root_router("/api/v1", [usuarios_router, tickets_router])
52
88
  """
53
89
  root = APIRouter(
54
90
  prefix=prefix,
@@ -56,7 +92,7 @@ def build_root_router(
56
92
  tags=t.cast(t.Any, tags),
57
93
  **router_kwargs,
58
94
  )
59
- for child_prefix, child in children.items():
95
+ for child_prefix, child in _iter_children(children):
60
96
  root.include_router(child, prefix=child_prefix)
61
97
  return root
62
98
 
@@ -69,6 +69,11 @@ class CronJobModelMixin:
69
69
  last_run_at: Mapped[datetime | None] = mapped_column(
70
70
  DateTime(timezone=True), nullable=True, default=None
71
71
  )
72
+ # Nullable: las tablas que ya existen no tienen la columna, y una migración que la
73
+ # añadiera NOT NULL exigiría inventarle una descripción a cada job ya sembrado.
74
+ description: Mapped[str | None] = mapped_column(
75
+ String(512), nullable=True, default=None
76
+ )
72
77
 
73
78
  @declared_attr
74
79
  def payload(cls) -> Mapped[dict[str, t.Any]]: # noqa: N805
@@ -85,6 +90,7 @@ class CronJobModelMixin:
85
90
  queue=self.queue,
86
91
  is_active=self.is_active,
87
92
  last_run_at=self.last_run_at,
93
+ description=self.description,
88
94
  )
89
95
 
90
96
 
@@ -196,22 +202,28 @@ async def seed_cron_jobs(
196
202
  return 0
197
203
 
198
204
  statement = _insert_ignore(session, model)
205
+ # Un modelo propio anterior a la columna `description` no la tiene, y un INSERT de
206
+ # Core con una clave que no es columna revienta con `Unconsumed column names`. Se
207
+ # siembra lo que la tabla admita en vez de tirar el arranque entero.
208
+ columns = set(model.__table__.c.keys())
199
209
  inserted = 0
200
210
  # Fila a fila para que `rowcount` sea fiable en todos los dialectos: con
201
211
  # executemany varios drivers devuelven -1 y el contador mentiría. Son un puñado
202
212
  # de filas y esto corre una vez al arrancar.
203
213
  for job in missing:
214
+ values = {
215
+ "job_id": job.job_id,
216
+ "task_name": job.task_name,
217
+ "cron_expression": job.cron_expression,
218
+ "payload": job.payload,
219
+ "queue": job.queue,
220
+ "is_active": job.is_active,
221
+ "last_run_at": _as_utc(job.last_run_at) if job.last_run_at else None,
222
+ "description": job.description,
223
+ }
204
224
  result = await session.execute(
205
225
  statement,
206
- {
207
- "job_id": job.job_id,
208
- "task_name": job.task_name,
209
- "cron_expression": job.cron_expression,
210
- "payload": job.payload,
211
- "queue": job.queue,
212
- "is_active": job.is_active,
213
- "last_run_at": _as_utc(job.last_run_at) if job.last_run_at else None,
214
- },
226
+ {key: value for key, value in values.items() if key in columns},
215
227
  )
216
228
  if result.rowcount != 0:
217
229
  inserted += 1
@@ -271,6 +283,14 @@ async def create_cron_tables(
271
283
  sa.Column("queue", sa.String(64), nullable=False),
272
284
  sa.Column("is_active", sa.Boolean(), nullable=False),
273
285
  sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
286
+ sa.Column("description", sa.String(512), nullable=True),
287
+ )
288
+
289
+ Si la tabla es anterior a la columna `description`, la migración de actualización es::
290
+
291
+ op.add_column(
292
+ "hexcore_cron_jobs",
293
+ sa.Column("description", sa.String(512), nullable=True),
274
294
  )
275
295
  """
276
296
  from hexcore.infrastructure.repositories.orms.sqlalchemy.session import get_engine
@@ -290,6 +310,7 @@ def cron_job(
290
310
  payload: dict[str, t.Any] | None = None,
291
311
  queue: str | None = None,
292
312
  is_active: bool = True,
313
+ description: str | None = None,
293
314
  ) -> CronJobDefinition:
294
315
  """
295
316
  Construye un `CronJobDefinition` a partir de una función decorada con
@@ -299,11 +320,16 @@ def cron_job(
299
320
  escribir el nombre a mano es cómo se acaba con un cron que encola una tarea que ya
300
321
  se renombró, y el fallo aparece en el worker, no aquí.
301
322
 
323
+ `description` no la lee el scheduler: viaja a la tabla para que el panel que muestra
324
+ los crons pueda decir **qué hace** cada uno antes de que alguien lo desactive. Si no
325
+ se pasa, se usa la primera línea del docstring de la tarea, que suele ser justo eso.
326
+
302
327
  Uso::
303
328
 
304
329
  seed = [
305
330
  cron_job(cerrar_caja, "0 3 * * *"),
306
331
  cron_job(purgar_logs, "0 4 * * 0", payload={"days": 30}),
332
+ cron_job(facturar, "0 6 1 * *", description="Emite las facturas del mes."),
307
333
  ]
308
334
 
309
335
  Raises:
@@ -325,9 +351,29 @@ def cron_job(
325
351
  payload=payload or {},
326
352
  queue=queue or getattr(task, "__cqrs_queue__", "default"),
327
353
  is_active=is_active,
354
+ description=description or _first_docstring_line(task),
328
355
  )
329
356
 
330
357
 
358
+ def _first_docstring_line(task: t.Callable[..., t.Any]) -> str | None:
359
+ """
360
+ La primera línea del docstring de la tarea, o `None`.
361
+
362
+ Que el default salga del docstring y no quede vacío es lo que hace que la columna
363
+ sirva sin trabajo extra: la descripción que el operador necesita casi siempre ya está
364
+ escrita justo encima de la función.
365
+ """
366
+ doc = getattr(task, "__doc__", None)
367
+ if not doc:
368
+ return None
369
+
370
+ for line in doc.strip().splitlines():
371
+ stripped = line.strip()
372
+ if stripped:
373
+ return stripped
374
+ return None
375
+
376
+
331
377
  def _default_session_scope() -> t.AsyncContextManager[AsyncSession]:
332
378
  from hexcore.infrastructure.uow.scopes import session_scope
333
379
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 6.0.0
3
+ Version: 6.0.2
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
@@ -385,6 +385,18 @@ admin = build_root_router(
385
385
  mount_routers(app, [admin, (public_router, {"prefix": "/v1"})])
386
386
  ```
387
387
 
388
+ `children` acepta también una **secuencia**, y no es azúcar: un dict no puede tener dos claves
389
+ `""`, así que un raíz con varios hijos que **ya traen su propio prefijo** —lo normal cuando cada
390
+ feature declara sus rutas completas— no se puede expresar con un mapa.
391
+
392
+ ```python
393
+ # usuarios_router y tickets_router ya son APIRouter(prefix="/usuarios") y (prefix="/tickets").
394
+ api_v1 = build_root_router("/api/v1", [usuarios_router, tickets_router])
395
+
396
+ # Se pueden mezclar: un router pelado equivale a ("", router).
397
+ api_v1 = build_root_router("/api/v1", [usuarios_router, ("/reports", reports_router)])
398
+ ```
399
+
388
400
  ### Endpoints de listado y búsqueda
389
401
 
390
402
  ```python
@@ -706,6 +718,28 @@ otro origen (Mongo, Redis, un YAML), implementá `cqrs.ICronJobRepository`.
706
718
  `cron_job()` deriva el `task_name` de `__cqrs_task_name__`: escribirlo a mano es cómo se acaba
707
719
  con un cron que encola una tarea ya renombrada, y el fallo aparece en el worker.
708
720
 
721
+ Cada job lleva además una `description` que viaja a la tabla. El scheduler no la usa: es lo que
722
+ un panel le muestra al operador para que sepa **qué hace** un cron antes de desactivarlo — con
723
+ sólo `task_name` y una expresión cron no se distingue apagar algo inofensivo de dejar de
724
+ facturar. Por defecto sale de la primera línea del docstring de la tarea, que suele ser
725
+ exactamente eso:
726
+
727
+ ```python
728
+ @cqrs.background_task(queue="billing")
729
+ async def emitir_facturas() -> None:
730
+ """Emite las facturas del mes y las envía por email."""
731
+ ...
732
+
733
+ cqrs.cron_job(emitir_facturas, "0 6 1 * *")
734
+ # description == "Emite las facturas del mes y las envía por email."
735
+
736
+ cqrs.cron_job(cerrar_caja, "0 3 * * *", description="Cierra la caja del día.")
737
+ ```
738
+
739
+ > Si tu tabla es anterior a esta columna, la migración es un `add_column` nullable —
740
+ > `create_cron_tables()` lo trae escrito en su docstring. Un modelo propio que no la tenga no
741
+ > rompe: el seed inserta lo que la tabla admita.
742
+
709
743
  **Cómo decide si toca ejecutar.** No compara contra el minuto actual, sino que busca si hubo
710
744
  alguna ocurrencia entre `last_run_at` y ahora:
711
745
 
@@ -884,18 +918,23 @@ estructura de migraciones con Alembic.
884
918
 
885
919
  | Serie | Estado | Qué significa |
886
920
  | :-- | :-- | :-- |
887
- | **5.x** | ✅ **Activa** | La única soportada. Recibe features y correcciones. |
921
+ | **6.x** | ✅ **Activa** | La única soportada. Recibe features y correcciones. |
922
+ | **5.x** | ⛔ **Deprecada** | Misma superficie de API que 6.x. Migrar es actualizar la versión. |
888
923
  | **4.x** | ⛔ **Deprecada** | Aplicación **parcial**: le faltan el fix del event loop de Celery, las fachadas y la documentación alineada. |
889
924
  | **3.x** | ⛔ **Deprecada** | Aplicación **parcial**: tiene las correcciones P0/P1 pero ninguna de las factories de FastAPI. |
890
925
  | **2.x** | ⛔ **Deprecada** | Contiene los bugs silenciosos corregidos en 5.x (ver abajo). |
891
926
  | **1.x** | ⛔ **Deprecada** | Sin soporte de ningún tipo. |
892
927
 
893
- **Todo lo anterior a 5.0 está deprecado. Migrá a 5.x.**
928
+ **Todo lo anterior a 5.0 está deprecado. Migrá a 6.x.**
894
929
 
895
930
  3.0.0 y 4.0.0 existen sólo porque el trabajo se mergeó por fases y cada merge disparó un bump
896
931
  automático: **no son releases pensadas para usarse**, son cortes intermedios de la misma
897
932
  migración. 5.0.0 es la primera versión completa.
898
933
 
934
+ 6.0.0 es el mismo caso: la disparó un PR de documentación con un commit `feat!:`. No hay
935
+ **ninguna** ruptura de API entre 5.x y 6.x — los alias anteriores a 5.0 siguen importables y
936
+ siguen avisando.
937
+
899
938
  ### Por qué 2.x y anteriores no deberían estar en producción
900
939
 
901
940
  No es una cuestión de estilo: 2.x tiene defectos que no lanzan excepción y no aparecen en logs
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexcore"
3
- version = "6.0.0"
3
+ version = "6.0.2"
4
4
  authors = [
5
5
  { name="David Latosefki (Indroic)", email="indroic@outlook.com" },
6
6
  ]
@@ -282,6 +282,74 @@ def test_build_root_router_supports_empty_child_prefix():
282
282
  assert client.get("/root/items").json() == {"from": "direct"}
283
283
 
284
284
 
285
+ def _self_prefixed_child(prefix: str, name: str) -> APIRouter:
286
+ """Un hijo que ya declara su propia ruta completa: no le queda sub-prefijo que poner."""
287
+ router = APIRouter(prefix=prefix)
288
+
289
+ @router.get("/items")
290
+ async def items() -> dict[str, str]:
291
+ return {"from": name}
292
+
293
+ return router
294
+
295
+
296
+ def test_build_root_router_accepts_two_children_without_sub_prefix():
297
+ """
298
+ El caso que un `Mapping` **no puede expresar**: dos hijos que ya traen sus rutas
299
+ completas, o sea dos sub-prefijos vacíos. Un dict no admite dos claves `""`, así que
300
+ con la firma anterior este root router había que escribirse a mano.
301
+ """
302
+ root = build_root_router(
303
+ "/api/v1",
304
+ [
305
+ _self_prefixed_child("/usuarios", "usuarios"),
306
+ _self_prefixed_child("/tickets", "tickets"),
307
+ ],
308
+ )
309
+ app = FastAPI()
310
+ app.include_router(root)
311
+
312
+ with TestClient(app) as client:
313
+ assert client.get("/api/v1/usuarios/items").json() == {"from": "usuarios"}
314
+ assert client.get("/api/v1/tickets/items").json() == {"from": "tickets"}
315
+
316
+
317
+ def test_build_root_router_sequence_mixes_bare_routers_and_pairs():
318
+ root = build_root_router(
319
+ "/api",
320
+ [_child("raiz"), ("/reports", _child("reports"))],
321
+ )
322
+ app = FastAPI()
323
+ app.include_router(root)
324
+
325
+ with TestClient(app) as client:
326
+ assert client.get("/api/items").json() == {"from": "raiz"}
327
+ assert client.get("/api/reports/items").json() == {"from": "reports"}
328
+
329
+
330
+ def test_build_root_router_sequence_still_applies_dependencies_and_tags():
331
+ """Las dos formas de `children` tienen que ser equivalentes en todo lo demás."""
332
+ calls: list[str] = []
333
+
334
+ async def guard() -> None:
335
+ calls.append("checked")
336
+
337
+ root = build_root_router(
338
+ "/secure",
339
+ [("/a", _child("a"))],
340
+ dependencies=[Depends(guard)],
341
+ tags=["admin"],
342
+ )
343
+ app = FastAPI()
344
+ app.include_router(root)
345
+
346
+ with TestClient(app) as client:
347
+ assert client.get("/secure/a/items").status_code == 200
348
+
349
+ assert calls == ["checked"]
350
+ assert app.openapi()["paths"]["/secure/a/items"]["get"]["tags"] == ["admin"]
351
+
352
+
285
353
  def test_mount_routers_accepts_plain_routers_and_tuples():
286
354
  app = FastAPI()
287
355
  mount_routers(