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.
- {hexcore-6.0.0/hexcore.egg-info → hexcore-6.0.2}/PKG-INFO +42 -3
- {hexcore-6.0.0 → hexcore-6.0.2}/README.md +41 -2
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/_deprecation.py +9 -2
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/cron.py +10 -1
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/routing.py +40 -4
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/cron_sql.py +55 -9
- {hexcore-6.0.0 → hexcore-6.0.2/hexcore.egg-info}/PKG-INFO +42 -3
- {hexcore-6.0.0 → hexcore-6.0.2}/pyproject.toml +1 -1
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_middlewares_and_routing.py +68 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_cron_sql.py +106 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_deprecations.py +60 -1
- {hexcore-6.0.0 → hexcore-6.0.2}/LICENSE +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/MANIFEST.in +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/__main__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/adapters.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/config.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/factory.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/in_memory_buses.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/pipeline.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/registry.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/cqrs/scheduler.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/base.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/cursor.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/errors.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/dtos/query.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/config.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/cqrs.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/base.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/buses.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/commands.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/context.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/decorators.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/exceptions.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/handlers.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/middleware.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/queries.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/resolution.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/serializer.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/cqrs/task_queues.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/events.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/exceptions.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/repositories.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/services.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/domain/uow.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/fastapi.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/app.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/cqrs.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/exception_handlers.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/health.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/lifespan.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/middlewares.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/rate_limit.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/streaming.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/api/utils.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cache/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cli.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/postgres_lock.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/cqrs/redis_lock.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/base.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/implementations.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/repositories/utils.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/task_queues/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/task_queues/celery_adapter.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/uow/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/uow/scopes.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/workers/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/workers/consumer.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/infrastructure/workers/runner.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/py.typed +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/sql.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/testing/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/testing/fakes.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/testing/fixtures.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/testing/helpers.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore/types.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/SOURCES.txt +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/requires.txt +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/hexcore.egg-info/top_level.txt +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/scripts/__init__.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/scripts/main.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/setup.cfg +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/conftest.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_app_and_lifespan.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_cqrs_providers.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_exception_handlers.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_health.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_rate_limit.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_api_streaming.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_basic.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_beanie_query_utils.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_celery_event_loop.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_config_loading.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_consumer_optional_event_bus.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_cqrs.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_cqrs_factory.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_cqrs_middlewares.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_documentation_examples.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_domain_service_query.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_dotted_resolution.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_facades.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_handler_registry.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_infrastructure_query_path.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_lock_error_policy.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_optional_dependencies.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_postgres_bus.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_postgres_lock.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_query_cursor_and_endpoint.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_query_field_validation.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_rabbitmq_bus.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_redis_bus.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_redis_lock.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_repositories_utils.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_scheduler.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_scheduler_catchup.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_smart_routing.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_sql_session_layer.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_task_queues_adapters.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_testing_utilities.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_uow_session_regression.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_use_cases_query.py +0 -0
- {hexcore-6.0.0 → hexcore-6.0.2}/tests/test_worker_bus_integration.py +0 -0
- {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.
|
|
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
|
-
| **
|
|
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
|
|
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
|
-
| **
|
|
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
|
|
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
|
+
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
|
-
|
|
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:
|
|
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:
|
|
35
|
-
|
|
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
|
|
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.
|
|
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
|
-
| **
|
|
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
|
|
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
|
|
@@ -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(
|