hexcore 3.0.0__tar.gz → 4.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. {hexcore-3.0.0/hexcore.egg-info → hexcore-4.0.0}/PKG-INFO +1 -1
  2. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/cqrs/in_memory_buses.py +7 -3
  3. hexcore-4.0.0/hexcore/application/dtos/cursor.py +110 -0
  4. hexcore-4.0.0/hexcore/application/dtos/errors.py +30 -0
  5. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/config.py +5 -0
  6. hexcore-4.0.0/hexcore/infrastructure/api/__init__.py +76 -0
  7. hexcore-4.0.0/hexcore/infrastructure/api/app.py +131 -0
  8. hexcore-4.0.0/hexcore/infrastructure/api/cqrs.py +195 -0
  9. hexcore-4.0.0/hexcore/infrastructure/api/exception_handlers.py +131 -0
  10. hexcore-4.0.0/hexcore/infrastructure/api/health.py +255 -0
  11. hexcore-4.0.0/hexcore/infrastructure/api/lifespan.py +340 -0
  12. hexcore-4.0.0/hexcore/infrastructure/api/middlewares.py +142 -0
  13. hexcore-4.0.0/hexcore/infrastructure/api/rate_limit.py +165 -0
  14. hexcore-4.0.0/hexcore/infrastructure/api/routing.py +87 -0
  15. hexcore-4.0.0/hexcore/infrastructure/api/streaming.py +293 -0
  16. hexcore-4.0.0/hexcore/infrastructure/api/utils.py +346 -0
  17. hexcore-4.0.0/hexcore/infrastructure/cqrs/__init__.py +29 -0
  18. hexcore-4.0.0/hexcore/infrastructure/cqrs/cron_sql.py +340 -0
  19. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/implementations.py +24 -0
  20. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +3 -1
  21. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +140 -1
  22. hexcore-4.0.0/hexcore/infrastructure/workers/__init__.py +19 -0
  23. hexcore-4.0.0/hexcore/infrastructure/workers/runner.py +349 -0
  24. hexcore-4.0.0/hexcore/testing/__init__.py +27 -0
  25. hexcore-4.0.0/hexcore/testing/fakes.py +175 -0
  26. hexcore-4.0.0/hexcore/testing/fixtures.py +105 -0
  27. hexcore-4.0.0/hexcore/testing/helpers.py +147 -0
  28. {hexcore-3.0.0 → hexcore-4.0.0/hexcore.egg-info}/PKG-INFO +1 -1
  29. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore.egg-info/SOURCES.txt +29 -1
  30. {hexcore-3.0.0 → hexcore-4.0.0}/pyproject.toml +4 -1
  31. hexcore-4.0.0/tests/test_api_app_and_lifespan.py +428 -0
  32. hexcore-4.0.0/tests/test_api_cqrs_providers.py +197 -0
  33. hexcore-4.0.0/tests/test_api_exception_handlers.py +151 -0
  34. hexcore-4.0.0/tests/test_api_health.py +220 -0
  35. hexcore-4.0.0/tests/test_api_middlewares_and_routing.py +308 -0
  36. hexcore-4.0.0/tests/test_api_rate_limit.py +228 -0
  37. hexcore-4.0.0/tests/test_api_streaming.py +367 -0
  38. hexcore-4.0.0/tests/test_cron_sql.py +351 -0
  39. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_optional_dependencies.py +7 -0
  40. hexcore-4.0.0/tests/test_query_cursor_and_endpoint.py +504 -0
  41. hexcore-4.0.0/tests/test_testing_utilities.py +330 -0
  42. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_use_cases_query.py +3 -1
  43. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_worker_bus_integration.py +38 -0
  44. hexcore-4.0.0/tests/test_worker_runner.py +363 -0
  45. hexcore-3.0.0/hexcore/infrastructure/api/__init__.py +0 -3
  46. hexcore-3.0.0/hexcore/infrastructure/api/utils.py +0 -195
  47. hexcore-3.0.0/hexcore/infrastructure/cqrs/__init__.py +0 -24
  48. hexcore-3.0.0/hexcore/infrastructure/workers/__init__.py +0 -1
  49. {hexcore-3.0.0 → hexcore-4.0.0}/LICENSE +0 -0
  50. {hexcore-3.0.0 → hexcore-4.0.0}/MANIFEST.in +0 -0
  51. {hexcore-3.0.0 → hexcore-4.0.0}/README.md +0 -0
  52. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/__init__.py +0 -0
  53. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/__main__.py +0 -0
  54. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/__init__.py +0 -0
  55. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/cqrs/__init__.py +0 -0
  56. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/cqrs/adapters.py +0 -0
  57. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/cqrs/config.py +0 -0
  58. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/cqrs/factory.py +0 -0
  59. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/cqrs/pipeline.py +0 -0
  60. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/cqrs/registry.py +0 -0
  61. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/cqrs/scheduler.py +0 -0
  62. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/dtos/__init__.py +0 -0
  63. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/dtos/base.py +0 -0
  64. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/dtos/query.py +0 -0
  65. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/use_cases/__init__.py +0 -0
  66. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/use_cases/base.py +0 -0
  67. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/application/use_cases/query.py +0 -0
  68. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/__init__.py +0 -0
  69. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/auth/__init__.py +0 -0
  70. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/auth/permissions.py +0 -0
  71. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/auth/value_objects.py +0 -0
  72. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/base.py +0 -0
  73. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/__init__.py +0 -0
  74. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/buses.py +0 -0
  75. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/commands.py +0 -0
  76. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/context.py +0 -0
  77. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/cron.py +0 -0
  78. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/decorators.py +0 -0
  79. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  80. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/handlers.py +0 -0
  81. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/middleware.py +0 -0
  82. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/queries.py +0 -0
  83. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/resolution.py +0 -0
  84. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/serializer.py +0 -0
  85. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/cqrs/task_queues.py +0 -0
  86. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/events.py +0 -0
  87. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/exceptions.py +0 -0
  88. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/repositories.py +0 -0
  89. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/services.py +0 -0
  90. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/domain/uow.py +0 -0
  91. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/__init__.py +0 -0
  92. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  93. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  94. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  95. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  96. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cli.py +0 -0
  97. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
  98. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
  99. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cqrs/postgres_lock.py +0 -0
  100. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  101. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  102. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  103. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
  104. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/cqrs/redis_lock.py +0 -0
  105. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/events/__init__.py +0 -0
  106. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  107. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  108. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  109. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/base.py +0 -0
  110. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  111. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  112. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  113. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  114. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  115. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/repositories/utils.py +0 -0
  116. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
  117. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/task_queues/celery_adapter.py +0 -0
  118. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
  119. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/uow/__init__.py +0 -0
  120. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  121. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  122. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/uow/scopes.py +0 -0
  123. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/workers/consumer.py +0 -0
  124. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
  125. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/py.typed +0 -0
  126. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore/types.py +0 -0
  127. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore.egg-info/dependency_links.txt +0 -0
  128. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore.egg-info/entry_points.txt +0 -0
  129. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore.egg-info/requires.txt +0 -0
  130. {hexcore-3.0.0 → hexcore-4.0.0}/hexcore.egg-info/top_level.txt +0 -0
  131. {hexcore-3.0.0 → hexcore-4.0.0}/scripts/__init__.py +0 -0
  132. {hexcore-3.0.0 → hexcore-4.0.0}/scripts/main.py +0 -0
  133. {hexcore-3.0.0 → hexcore-4.0.0}/setup.cfg +0 -0
  134. {hexcore-3.0.0 → hexcore-4.0.0}/tests/conftest.py +0 -0
  135. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_basic.py +0 -0
  136. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_beanie_query_utils.py +0 -0
  137. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_config_loading.py +0 -0
  138. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_consumer_optional_event_bus.py +0 -0
  139. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_cqrs.py +0 -0
  140. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_cqrs_factory.py +0 -0
  141. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_cqrs_middlewares.py +0 -0
  142. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_domain_service_query.py +0 -0
  143. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_dotted_resolution.py +0 -0
  144. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_handler_registry.py +0 -0
  145. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_infrastructure_query_path.py +0 -0
  146. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_lock_error_policy.py +0 -0
  147. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_postgres_bus.py +0 -0
  148. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_postgres_lock.py +0 -0
  149. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_query_field_validation.py +0 -0
  150. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_rabbitmq_bus.py +0 -0
  151. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_redis_bus.py +0 -0
  152. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_redis_lock.py +0 -0
  153. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_repositories_utils.py +0 -0
  154. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_scheduler.py +0 -0
  155. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_scheduler_catchup.py +0 -0
  156. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_smart_routing.py +0 -0
  157. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_sql_session_layer.py +0 -0
  158. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_task_queues_adapters.py +0 -0
  159. {hexcore-3.0.0 → hexcore-4.0.0}/tests/test_uow_session_regression.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 3.0.0
3
+ Version: 4.0.0
4
4
  Summary: Núcleo reutilizable para proyectos Python con arquitectura hexagonal y event handling. Provee abstracciones, utilidades y contratos para DDD, eventos y desacoplamiento de infraestructura.
5
5
  Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
6
6
  License-Expression: MIT
@@ -56,7 +56,10 @@ class InMemoryCommandBus(ICommandBus):
56
56
  is_background = getattr(cmd_type, "__cqrs_background__", False)
57
57
 
58
58
  if is_background and not is_worker_execution():
59
- if not self._enqueuer or not self._serializer:
59
+ # `is None` y no truthiness: un enqueuer que implemente `__len__` o
60
+ # `__bool__` (un doble de test que cuenta lo encolado, p. ej.) es falsy
61
+ # cuando está vacío, y con `not self._enqueuer` se descartaría.
62
+ if self._enqueuer is None or self._serializer is None:
60
63
  raise RuntimeError(
61
64
  f"El comando '{cmd_type.__name__}' requiere ejecución en background, "
62
65
  "pero el InMemoryCommandBus no tiene configurado un 'enqueuer' o 'serializer'."
@@ -150,8 +153,9 @@ class InMemoryEventBus(IEventBus):
150
153
  )
151
154
 
152
155
  if is_background:
153
- # Enrutamiento hacia background
154
- if not self._enqueuer or not self._serializer:
156
+ # Enrutamiento hacia background. `is None` por el mismo motivo que en
157
+ # `InMemoryCommandBus.dispatch`.
158
+ if self._enqueuer is None or self._serializer is None:
155
159
  raise RuntimeError(
156
160
  f"El handler de eventos '{event_handler.__name__}' requiere ejecución en background, "
157
161
  "pero el InMemoryEventBus no tiene configurado un 'enqueuer' o 'serializer'."
@@ -0,0 +1,110 @@
1
+ """
2
+ Paginación por cursor.
3
+
4
+ `limit`/`offset` degrada en tablas grandes: un `OFFSET 100000` obliga a la base a
5
+ escanear y descartar 100.000 filas antes de devolver la primera. Para los listados
6
+ grandes (tickets, líneas, movimientos) hace falta la variante por cursor, que salta
7
+ directo con un `WHERE (sort_key, id) > (...)`.
8
+
9
+ Se **añade**, no sustituye a `QueryResponseDTO`.
10
+
11
+ El cursor es opaco a propósito: codifica `(sort_key, id)` en base64url. Si fuera legible,
12
+ los clientes empezarían a construirlo a mano y quedaría congelado como API pública.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import binascii
18
+ import json
19
+ import typing as t
20
+
21
+ from pydantic import Field
22
+
23
+ from .base import DTO
24
+ from .query import FilterConditionDTO, SortDirection
25
+
26
+ T = t.TypeVar("T")
27
+
28
+ __all__ = [
29
+ "CursorPageDTO",
30
+ "CursorRequestDTO",
31
+ "encode_cursor",
32
+ "decode_cursor",
33
+ "InvalidCursorError",
34
+ ]
35
+
36
+
37
+ class InvalidCursorError(ValueError):
38
+ """El cursor recibido no se puede decodificar."""
39
+
40
+
41
+ class CursorPageDTO(DTO, t.Generic[T]):
42
+ """
43
+ Una página por cursor.
44
+
45
+ `next_cursor` es None cuando no hay más resultados, que es la única señal de fin que
46
+ necesita el cliente: no hay `total`, porque contar es justamente lo que esta
47
+ paginación evita.
48
+ """
49
+
50
+ items: list[T] = Field(default_factory=list)
51
+ next_cursor: str | None = None
52
+
53
+
54
+ class CursorRequestDTO(DTO):
55
+ """Petición de una página por cursor."""
56
+
57
+ limit: int = Field(default=50, ge=1, le=500)
58
+ cursor: str | None = None
59
+ sort_field: str = "created_at"
60
+ direction: SortDirection = SortDirection.DESC
61
+ search: str | None = None
62
+ search_fields: list[str] = Field(default_factory=list)
63
+ filters: list[FilterConditionDTO] = Field(default_factory=list)
64
+
65
+
66
+ def encode_cursor(sort_value: t.Any, entity_id: t.Any) -> str:
67
+ """
68
+ Codifica la posición de la última fila de la página.
69
+
70
+ Se incluye el `id` además del campo de orden para desempatar: con sólo el `sort_key`,
71
+ varias filas con el mismo `created_at` se saltarían o se repetirían.
72
+ """
73
+ payload = json.dumps(
74
+ {"k": _jsonable(sort_value), "i": _jsonable(entity_id)},
75
+ separators=(",", ":"),
76
+ )
77
+ return base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
78
+
79
+
80
+ def decode_cursor(cursor: str) -> tuple[t.Any, t.Any]:
81
+ """
82
+ Decodifica un cursor a `(sort_value, entity_id)`.
83
+
84
+ Raises:
85
+ InvalidCursorError: Si el cursor está corrupto o no tiene la forma esperada. Es
86
+ un `ValueError`, así que los handlers de F5 lo traducen a 422.
87
+ """
88
+ try:
89
+ padding = "=" * (-len(cursor) % 4)
90
+ payload = base64.urlsafe_b64decode(cursor + padding).decode()
91
+ data = json.loads(payload)
92
+ except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
93
+ raise InvalidCursorError(f"Cursor inválido: {cursor!r}") from exc
94
+
95
+ if not isinstance(data, dict) or "k" not in data or "i" not in data:
96
+ raise InvalidCursorError(f"Cursor inválido: {cursor!r}")
97
+
98
+ return data["k"], data["i"]
99
+
100
+
101
+ def _jsonable(value: t.Any) -> t.Any:
102
+ """Normaliza a algo serializable en JSON conservando el orden lexicográfico."""
103
+ if value is None or isinstance(value, (str, int, float, bool)):
104
+ return value
105
+ isoformat = getattr(value, "isoformat", None)
106
+ if callable(isoformat):
107
+ # ISO-8601 ordena lexicográficamente igual que cronológicamente, que es lo que
108
+ # necesita la comparación del WHERE.
109
+ return isoformat()
110
+ return str(value)
@@ -0,0 +1,30 @@
1
+ """
2
+ Errores de validación de campos de query, con datos estructurados.
3
+
4
+ `ValueError("Campo de orden no soportado: nope")` sólo se puede mostrar; el cliente no
5
+ puede señalar el input concreto ni ofrecer las alternativas. Esta subclase lleva `field` y
6
+ `allowed`, y sigue siendo un `ValueError`, así que todo el código que ya lo capturaba
7
+ —incluidos los handlers de F5— sigue funcionando.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import typing as t
12
+
13
+ __all__ = ["UnsupportedQueryFieldError"]
14
+
15
+
16
+ class UnsupportedQueryFieldError(ValueError):
17
+ """Un campo de filtro, orden o búsqueda que el modelo no expone."""
18
+
19
+ def __init__(
20
+ self,
21
+ field: str,
22
+ context: str,
23
+ allowed: t.Iterable[str] | None = None,
24
+ ) -> None:
25
+ self.field = field
26
+ self.context = context
27
+ self.allowed: list[str] = sorted(allowed) if allowed is not None else []
28
+ # Se conserva el mensaje anterior palabra por palabra: hay tests y logs que lo
29
+ # buscan, y cambiarlo no aporta nada.
30
+ super().__init__(f"Campo de {context} no soportado: {field}")
@@ -21,6 +21,11 @@ class ServerConfig(BaseModel):
21
21
  port: int = 8000
22
22
  debug: bool = True
23
23
 
24
+ # Identidad de la app. La usa `create_app()` para el título y la versión de FastAPI,
25
+ # de modo que el camino feliz no necesite pasarlos.
26
+ app_title: str = "HexCore API"
27
+ app_version: str = "0.1.0"
28
+
24
29
  # DB CONFIG
25
30
  sql_database_url: str = "sqlite:///./db.sqlite3"
26
31
  async_sql_database_url: str = "sqlite+aiosqlite:///./db.sqlite3"
@@ -0,0 +1,76 @@
1
+ from .cqrs import (
2
+ CQRSContainer,
3
+ configure_cqrs,
4
+ get_cqrs_container,
5
+ provide_command_bus,
6
+ provide_event_bus,
7
+ provide_query_bus,
8
+ provide_registry,
9
+ reset_cqrs,
10
+ )
11
+ from .exception_handlers import (
12
+ DEFAULT_EXCEPTION_STATUS_MAP,
13
+ register_exception_handlers,
14
+ )
15
+ from .health import (
16
+ DependencyReport,
17
+ HealthReport,
18
+ Probe,
19
+ check_health,
20
+ default_probes,
21
+ register_health_routes,
22
+ )
23
+ from .middlewares import (
24
+ REQUEST_ID,
25
+ RequestIDLogFilter,
26
+ RequestIDMiddleware,
27
+ TimingMiddleware,
28
+ get_request_id,
29
+ install_request_id_logging,
30
+ )
31
+ from .rate_limit import client_ip_key, rate_limit
32
+ from .routing import build_root_router, mount_routers
33
+ from .streaming import connection_slot, format_sse_event, sse_stream, ws_heartbeat
34
+ from .utils import build_query_endpoint, register_query_endpoint
35
+
36
+ __all__ = [
37
+ "build_query_endpoint",
38
+ "register_query_endpoint",
39
+ # Middlewares (F11)
40
+ "REQUEST_ID",
41
+ "RequestIDMiddleware",
42
+ "TimingMiddleware",
43
+ "RequestIDLogFilter",
44
+ "get_request_id",
45
+ "install_request_id_logging",
46
+ # Routing (F13)
47
+ "build_root_router",
48
+ "mount_routers",
49
+ # Handlers de excepción (F5)
50
+ "DEFAULT_EXCEPTION_STATUS_MAP",
51
+ "register_exception_handlers",
52
+ # Providers CQRS (F6)
53
+ "CQRSContainer",
54
+ "configure_cqrs",
55
+ "get_cqrs_container",
56
+ "reset_cqrs",
57
+ "provide_command_bus",
58
+ "provide_query_bus",
59
+ "provide_event_bus",
60
+ "provide_registry",
61
+ # Rate limiting (F12)
62
+ "rate_limit",
63
+ "client_ip_key",
64
+ # Streaming (F14)
65
+ "sse_stream",
66
+ "format_sse_event",
67
+ "ws_heartbeat",
68
+ "connection_slot",
69
+ # Health checks (F16)
70
+ "check_health",
71
+ "register_health_routes",
72
+ "default_probes",
73
+ "HealthReport",
74
+ "DependencyReport",
75
+ "Probe",
76
+ ]
@@ -0,0 +1,131 @@
1
+ """
2
+ Factory de la aplicación FastAPI.
3
+
4
+ Cada app repite CORS, middleware de request-id, handlers de excepción y health checks.
5
+ Aplicando las reglas de simplicidad del plan: el camino feliz es cero configuración
6
+ (`create_app()` a secas produce una app usable), y los interruptores van agrupados en un
7
+ solo objeto en vez de ocho keywords.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import typing as t
12
+
13
+ from fastapi import FastAPI
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from pydantic import BaseModel
16
+
17
+ from .exception_handlers import register_exception_handlers
18
+ from .health import Probe, register_health_routes
19
+ from .middlewares import RequestIDMiddleware, TimingMiddleware
20
+ from .routing import MountableRouter, mount_routers
21
+
22
+ __all__ = ["AppFeatures", "create_app"]
23
+
24
+
25
+ class AppFeatures(BaseModel):
26
+ """
27
+ Qué cablea `create_app()`. Todo activado por defecto.
28
+
29
+ Desactivar es explícito y localizado: ``create_app(features=AppFeatures(cors=False))``.
30
+ """
31
+
32
+ cors: bool = True
33
+ """CORS con `config.allow_origins` / `allow_methods` / `allow_headers`."""
34
+
35
+ request_id: bool = True
36
+ """Middleware `X-Request-ID` (F11)."""
37
+
38
+ timing: bool = True
39
+ """Middleware `X-Response-Time-ms` (F11)."""
40
+
41
+ exception_handlers: bool = True
42
+ """Mapeo de excepciones de dominio a HTTP (F5)."""
43
+
44
+ health: bool = True
45
+ """Rutas `/health` y `/health/ready` (F16)."""
46
+
47
+
48
+ def create_app(
49
+ lifespan: t.Any | None = None,
50
+ *,
51
+ features: AppFeatures | None = None,
52
+ routers: t.Sequence[MountableRouter] | None = None,
53
+ health_probes: t.Sequence[Probe] | None = None,
54
+ exception_mapping: dict[type[Exception], int] | None = None,
55
+ **fastapi_kwargs: t.Any,
56
+ ) -> FastAPI:
57
+ """
58
+ Construye una `FastAPI` con lo que toda app necesita ya cableado.
59
+
60
+ `create_app()` sin argumentos ya da una app usable: `title` y `version` salen de
61
+ `ServerConfig`, CORS de `config.allow_origins`, y `/health` existe.
62
+
63
+ Args:
64
+ lifespan: El lifespan. Típicamente de `build_lifespan(...)` (F4).
65
+ features: Qué cablear. Ver `AppFeatures`.
66
+ routers: Routers a montar. Acepta `APIRouter` o `(APIRouter, kwargs)` (F13).
67
+ health_probes: Sondas para `/health/ready`. Por defecto, las deducidas de la
68
+ configuración (F16).
69
+ exception_mapping: Excepciones extra a mapear, fusionadas con el default (F5).
70
+ **fastapi_kwargs: Se pasan tal cual a `FastAPI` (`title`, `version`,
71
+ `docs_url`, `openapi_tags`…). Lo que pases gana sobre los defaults derivados
72
+ de la configuración.
73
+
74
+ Returns:
75
+ La aplicación, lista para servir.
76
+ """
77
+ resolved_features = features or AppFeatures()
78
+ config = _config()
79
+
80
+ app = FastAPI(lifespan=lifespan, **{**_fastapi_defaults(config), **fastapi_kwargs})
81
+
82
+ # Los middlewares de Starlette se ejecutan en orden inverso al de registro, así que
83
+ # el request-id se añade último para que envuelva a todo lo demás y su ContextVar
84
+ # esté disponible en el resto de la pila.
85
+ if resolved_features.cors:
86
+ app.add_middleware(
87
+ CORSMiddleware,
88
+ allow_origins=list(getattr(config, "allow_origins", ["*"])),
89
+ allow_credentials=bool(getattr(config, "allow_credentials", True)),
90
+ allow_methods=list(getattr(config, "allow_methods", ["*"])),
91
+ allow_headers=list(getattr(config, "allow_headers", ["*"])),
92
+ )
93
+ if resolved_features.timing:
94
+ app.add_middleware(TimingMiddleware)
95
+ if resolved_features.request_id:
96
+ app.add_middleware(RequestIDMiddleware)
97
+
98
+ if resolved_features.exception_handlers:
99
+ register_exception_handlers(app, mapping=exception_mapping)
100
+
101
+ if resolved_features.health:
102
+ register_health_routes(app, probes=health_probes)
103
+
104
+ if routers:
105
+ mount_routers(app, list(routers))
106
+
107
+ return app
108
+
109
+
110
+ def _fastapi_defaults(config: t.Any) -> dict[str, t.Any]:
111
+ """
112
+ Defaults derivados de `ServerConfig`.
113
+
114
+ `debug` gobierna la documentación interactiva: en producción, `/docs` abierto es una
115
+ decisión que hay que tomar a propósito, no heredar.
116
+ """
117
+ debug = bool(getattr(config, "debug", False))
118
+ return {
119
+ "title": getattr(config, "app_title", None) or "HexCore API",
120
+ "version": getattr(config, "app_version", None) or "0.1.0",
121
+ "debug": debug,
122
+ "docs_url": "/docs" if debug else None,
123
+ "redoc_url": "/redoc" if debug else None,
124
+ "openapi_url": "/openapi.json" if debug else None,
125
+ }
126
+
127
+
128
+ def _config() -> t.Any:
129
+ from hexcore.config import LazyConfig
130
+
131
+ return LazyConfig.get_config()
@@ -0,0 +1,195 @@
1
+ """
2
+ Providers FastAPI para los buses CQRS.
3
+
4
+ Existen como funciones —y no como accesos directos al contenedor— por una única razón:
5
+ `app.dependency_overrides[provide_command_bus] = ...` en los tests. Si el endpoint
6
+ tocara el singleton, no habría nada que sobreescribir.
7
+
8
+ **Una sola fuente de verdad.** El contenedor que leen estos providers es el mismo que
9
+ usa el worker: se configura una vez con `configure_cqrs(...)` y tanto la app web como el
10
+ entrypoint del worker lo consumen. Duplicar la construcción es cómo se acaba con un
11
+ registry en el web que no tiene los handlers del worker.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ import typing as t
17
+
18
+ from hexcore.domain.cqrs.buses import (
19
+ AbstractCommandBus,
20
+ AbstractEventBus,
21
+ AbstractQueryBus,
22
+ )
23
+
24
+ if t.TYPE_CHECKING:
25
+ from hexcore.application.cqrs.factory import CQRSFactory
26
+ from hexcore.application.cqrs.registry import HandlerRegistry
27
+ from hexcore.domain.cqrs.serializer import ISerializer
28
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
29
+ from hexcore.infrastructure.workers.consumer import CQRSConsumer
30
+
31
+ __all__ = [
32
+ "CQRSContainer",
33
+ "configure_cqrs",
34
+ "get_cqrs_container",
35
+ "reset_cqrs",
36
+ "provide_command_bus",
37
+ "provide_query_bus",
38
+ "provide_event_bus",
39
+ "provide_registry",
40
+ ]
41
+
42
+
43
+ class CQRSContainer:
44
+ """
45
+ Contenedor perezoso de los buses CQRS.
46
+
47
+ Construye los buses la primera vez que se piden y los cachea. Perezoso a propósito:
48
+ `configure_cqrs()` se puede llamar en import time sin tocar la BD ni el broker.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ factory: "CQRSFactory",
54
+ ) -> None:
55
+ self._factory = factory
56
+ self._command_bus: AbstractCommandBus | None = None
57
+ self._query_bus: AbstractQueryBus | None = None
58
+ self._event_bus: AbstractEventBus | None = None
59
+ self._lock = threading.RLock()
60
+
61
+ @property
62
+ def registry(self) -> "HandlerRegistry":
63
+ return self._factory._registry # noqa: SLF001 - misma capa lógica
64
+
65
+ @property
66
+ def serializer(self) -> "ISerializer":
67
+ return self._factory.create_serializer()
68
+
69
+ def command_bus(self) -> AbstractCommandBus:
70
+ with self._lock:
71
+ if self._command_bus is None:
72
+ self._command_bus = t.cast(
73
+ AbstractCommandBus, self._factory.create_command_bus()
74
+ )
75
+ return self._command_bus
76
+
77
+ def query_bus(self) -> AbstractQueryBus:
78
+ with self._lock:
79
+ if self._query_bus is None:
80
+ self._query_bus = t.cast(
81
+ AbstractQueryBus, self._factory.create_query_bus()
82
+ )
83
+ return self._query_bus
84
+
85
+ def event_bus(self) -> AbstractEventBus:
86
+ with self._lock:
87
+ if self._event_bus is None:
88
+ self._event_bus = t.cast(
89
+ AbstractEventBus, self._factory.create_event_bus()
90
+ )
91
+ return self._event_bus
92
+
93
+ def build_consumer(self) -> "CQRSConsumer":
94
+ """
95
+ Construye el `CQRSConsumer` del worker sobre **estos mismos** buses.
96
+
97
+ Es lo que garantiza que el worker y la app web comparten registry y serializer.
98
+ """
99
+ from hexcore.infrastructure.workers.consumer import CQRSConsumer
100
+
101
+ return CQRSConsumer(
102
+ command_bus=self.command_bus(),
103
+ event_bus=self.event_bus(),
104
+ serializer=self.serializer,
105
+ )
106
+
107
+
108
+ _container: CQRSContainer | None = None
109
+ _container_lock = threading.RLock()
110
+
111
+
112
+ def configure_cqrs(
113
+ registry: "HandlerRegistry",
114
+ *,
115
+ config: t.Any = None,
116
+ enqueuer: "ITaskEnqueuer | None" = None,
117
+ ) -> CQRSContainer:
118
+ """
119
+ Configura el contenedor CQRS del proceso. Llamalo una vez, al arrancar.
120
+
121
+ Args:
122
+ registry: El registry con los handlers ya registrados.
123
+ config: Un `CQRSConfig`. Por defecto, el de `ServerConfig.cqrs` si está, o
124
+ `CQRSConfig()`.
125
+ enqueuer: El `ITaskEnqueuer` para el Smart Routing. Sin él, un
126
+ `@background_command` registrado hace fallar la construcción del bus (P0-5).
127
+
128
+ Returns:
129
+ El contenedor, por si querés usarlo directamente (p. ej. `build_consumer()`).
130
+ """
131
+ from hexcore.application.cqrs.config import CQRSConfig
132
+ from hexcore.application.cqrs.factory import CQRSFactory
133
+
134
+ global _container
135
+
136
+ if config is None:
137
+ from hexcore.config import LazyConfig
138
+
139
+ config = getattr(LazyConfig.get_config(), "cqrs", None) or CQRSConfig()
140
+
141
+ with _container_lock:
142
+ _container = CQRSContainer(
143
+ CQRSFactory(config, registry, enqueuer=enqueuer)
144
+ )
145
+ return _container
146
+
147
+
148
+ def get_cqrs_container() -> CQRSContainer:
149
+ """
150
+ El contenedor configurado.
151
+
152
+ Raises:
153
+ RuntimeError: Si nadie llamó a `configure_cqrs()`.
154
+ """
155
+ with _container_lock:
156
+ if _container is None:
157
+ raise RuntimeError(
158
+ "El CQRS no está configurado. Llamá a "
159
+ "hexcore.infrastructure.api.cqrs.configure_cqrs(registry, enqueuer=...) "
160
+ "al arrancar la aplicación (o el worker), antes de resolver cualquier bus."
161
+ )
162
+ return _container
163
+
164
+
165
+ def reset_cqrs() -> None:
166
+ """Descarta el contenedor. Para tests y para reconfigurar en un worker."""
167
+ global _container
168
+
169
+ with _container_lock:
170
+ _container = None
171
+
172
+
173
+ # ── Dependencias FastAPI ──────────────────────────────────────────────────────
174
+ # Deliberadamente triviales: su valor está en ser un objeto sobre el que hacer
175
+ # `app.dependency_overrides[...] = ...`.
176
+
177
+
178
+ def provide_command_bus() -> AbstractCommandBus:
179
+ """Dependencia FastAPI: el CommandBus del proceso."""
180
+ return get_cqrs_container().command_bus()
181
+
182
+
183
+ def provide_query_bus() -> AbstractQueryBus:
184
+ """Dependencia FastAPI: el QueryBus del proceso."""
185
+ return get_cqrs_container().query_bus()
186
+
187
+
188
+ def provide_event_bus() -> AbstractEventBus:
189
+ """Dependencia FastAPI: el EventBus del proceso."""
190
+ return get_cqrs_container().event_bus()
191
+
192
+
193
+ def provide_registry() -> "HandlerRegistry":
194
+ """Dependencia FastAPI: el `HandlerRegistry` del proceso."""
195
+ return get_cqrs_container().registry