hexcore 1.7.0__tar.gz → 1.8.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 (122) hide show
  1. hexcore-1.8.0/MANIFEST.in +1 -0
  2. {hexcore-1.7.0 → hexcore-1.8.0}/PKG-INFO +52 -1
  3. {hexcore-1.7.0 → hexcore-1.8.0}/README.md +51 -0
  4. hexcore-1.8.0/hexcore/application/dtos/__init__.py +23 -0
  5. hexcore-1.8.0/hexcore/application/dtos/query.py +66 -0
  6. hexcore-1.8.0/hexcore/application/use_cases/__init__.py +13 -0
  7. hexcore-1.8.0/hexcore/application/use_cases/query.py +37 -0
  8. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/config.py +57 -7
  9. hexcore-1.8.0/hexcore/domain/services.py +232 -0
  10. hexcore-1.8.0/hexcore/infrastructure/api/__init__.py +3 -0
  11. hexcore-1.8.0/hexcore/infrastructure/api/utils.py +172 -0
  12. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/cli.py +173 -114
  13. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/repositories/implementations.py +23 -0
  14. hexcore-1.8.0/hexcore/infrastructure/repositories/orms/beanie/utils.py +283 -0
  15. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +10 -3
  16. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +116 -1
  17. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/repositories/utils.py +22 -112
  18. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/uow/__init__.py +44 -29
  19. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore.egg-info/PKG-INFO +52 -1
  20. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore.egg-info/SOURCES.txt +12 -46
  21. {hexcore-1.7.0 → hexcore-1.8.0}/pyproject.toml +3 -3
  22. hexcore-1.8.0/scripts/main.py +10 -0
  23. hexcore-1.8.0/tests/conftest.py +21 -0
  24. hexcore-1.8.0/tests/test_beanie_query_utils.py +138 -0
  25. hexcore-1.8.0/tests/test_config_loading.py +187 -0
  26. hexcore-1.8.0/tests/test_domain_service_query.py +153 -0
  27. hexcore-1.8.0/tests/test_infrastructure_query_path.py +150 -0
  28. hexcore-1.8.0/tests/test_query_field_validation.py +218 -0
  29. {hexcore-1.7.0 → hexcore-1.8.0}/tests/test_repositories_utils.py +45 -8
  30. {hexcore-1.7.0 → hexcore-1.8.0}/tests/test_uow_session_regression.py +72 -3
  31. hexcore-1.8.0/tests/test_use_cases_query.py +224 -0
  32. hexcore-1.7.0/hexcore/__init__.pyi +0 -11
  33. hexcore-1.7.0/hexcore/application/__init__.pyi +0 -4
  34. hexcore-1.7.0/hexcore/application/dtos/__init__.py +0 -7
  35. hexcore-1.7.0/hexcore/application/dtos/__init__.pyi +0 -3
  36. hexcore-1.7.0/hexcore/application/dtos/base.pyi +0 -4
  37. hexcore-1.7.0/hexcore/application/use_cases/__init__.py +0 -7
  38. hexcore-1.7.0/hexcore/application/use_cases/__init__.pyi +0 -3
  39. hexcore-1.7.0/hexcore/application/use_cases/base.pyi +0 -11
  40. hexcore-1.7.0/hexcore/config.pyi +0 -35
  41. hexcore-1.7.0/hexcore/domain/auth/__init__.pyi +0 -4
  42. hexcore-1.7.0/hexcore/domain/auth/permissions.pyi +0 -10
  43. hexcore-1.7.0/hexcore/domain/auth/value_objects.pyi +0 -11
  44. hexcore-1.7.0/hexcore/domain/base.pyi +0 -19
  45. hexcore-1.7.0/hexcore/domain/events.pyi +0 -35
  46. hexcore-1.7.0/hexcore/domain/exceptions.pyi +0 -2
  47. hexcore-1.7.0/hexcore/domain/repositories.pyi +0 -22
  48. hexcore-1.7.0/hexcore/domain/services.py +0 -11
  49. hexcore-1.7.0/hexcore/domain/services.pyi +0 -8
  50. hexcore-1.7.0/hexcore/domain/uow.pyi +0 -24
  51. hexcore-1.7.0/hexcore/infrastructure/__init__.pyi +0 -0
  52. hexcore-1.7.0/hexcore/infrastructure/api/__init__.pyi +0 -0
  53. hexcore-1.7.0/hexcore/infrastructure/api/utils.py +0 -20
  54. hexcore-1.7.0/hexcore/infrastructure/api/utils.pyi +0 -9
  55. hexcore-1.7.0/hexcore/infrastructure/cache/__init__.pyi +0 -10
  56. hexcore-1.7.0/hexcore/infrastructure/cache/cache_backends/__init__.pyi +0 -0
  57. hexcore-1.7.0/hexcore/infrastructure/cache/cache_backends/memory.pyi +0 -10
  58. hexcore-1.7.0/hexcore/infrastructure/cache/cache_backends/redis.pyi +0 -12
  59. hexcore-1.7.0/hexcore/infrastructure/cli.pyi +0 -21
  60. hexcore-1.7.0/hexcore/infrastructure/events/__init__.pyi +0 -0
  61. hexcore-1.7.0/hexcore/infrastructure/events/events_backends/__init__.pyi +0 -0
  62. hexcore-1.7.0/hexcore/infrastructure/events/events_backends/memory.pyi +0 -8
  63. hexcore-1.7.0/hexcore/infrastructure/repositories/__init__.pyi +0 -0
  64. hexcore-1.7.0/hexcore/infrastructure/repositories/base.pyi +0 -17
  65. hexcore-1.7.0/hexcore/infrastructure/repositories/decorators.pyi +0 -5
  66. hexcore-1.7.0/hexcore/infrastructure/repositories/implementations.pyi +0 -52
  67. hexcore-1.7.0/hexcore/infrastructure/repositories/orms/__init__.pyi +0 -0
  68. hexcore-1.7.0/hexcore/infrastructure/repositories/orms/beanie/__init__.pyi +0 -14
  69. hexcore-1.7.0/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -141
  70. hexcore-1.7.0/hexcore/infrastructure/repositories/orms/beanie/utils.pyi +0 -29
  71. hexcore-1.7.0/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.pyi +0 -19
  72. hexcore-1.7.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.pyi +0 -9
  73. hexcore-1.7.0/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.pyi +0 -45
  74. hexcore-1.7.0/hexcore/infrastructure/repositories/utils.pyi +0 -14
  75. hexcore-1.7.0/hexcore/infrastructure/uow/__init__.pyi +0 -32
  76. hexcore-1.7.0/hexcore/infrastructure/uow/decorators.pyi +0 -4
  77. hexcore-1.7.0/hexcore/infrastructure/uow/helpers.pyi +0 -7
  78. hexcore-1.7.0/hexcore/types.pyi +0 -37
  79. hexcore-1.7.0/scripts/__init__.py +0 -0
  80. hexcore-1.7.0/scripts/fix_pyi_files.py +0 -32
  81. hexcore-1.7.0/scripts/fix_types_pyi_aliases.py +0 -8
  82. hexcore-1.7.0/scripts/main.py +0 -38
  83. {hexcore-1.7.0 → hexcore-1.8.0}/LICENSE +0 -0
  84. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/__init__.py +0 -0
  85. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/__main__.py +0 -0
  86. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/application/__init__.py +0 -0
  87. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/application/dtos/base.py +0 -0
  88. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/application/use_cases/base.py +0 -0
  89. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/domain/__init__.py +0 -0
  90. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/domain/auth/__init__.py +0 -0
  91. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/domain/auth/permissions.py +0 -0
  92. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/domain/auth/value_objects.py +0 -0
  93. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/domain/base.py +0 -0
  94. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/domain/events.py +0 -0
  95. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/domain/exceptions.py +0 -0
  96. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/domain/repositories.py +0 -0
  97. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/domain/uow.py +0 -0
  98. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/__init__.py +0 -0
  99. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  100. {hexcore-1.7.0/hexcore/infrastructure/api → hexcore-1.8.0/hexcore/infrastructure/cache/cache_backends}/__init__.py +0 -0
  101. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  102. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  103. {hexcore-1.7.0/hexcore/infrastructure/cache/cache_backends → hexcore-1.8.0/hexcore/infrastructure/events}/__init__.py +0 -0
  104. {hexcore-1.7.0/hexcore/infrastructure/events → hexcore-1.8.0/hexcore/infrastructure/events/events_backends}/__init__.py +0 -0
  105. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  106. {hexcore-1.7.0/hexcore/infrastructure/events/events_backends → hexcore-1.8.0/hexcore/infrastructure/repositories}/__init__.py +0 -0
  107. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/repositories/base.py +0 -0
  108. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  109. {hexcore-1.7.0/hexcore/infrastructure/repositories → hexcore-1.8.0/hexcore/infrastructure/repositories/orms}/__init__.py +0 -0
  110. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  111. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  112. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  113. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  114. /hexcore-1.7.0/hexcore/domain/__init__.pyi → /hexcore-1.8.0/hexcore/py.typed +0 -0
  115. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore/types.py +0 -0
  116. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore.egg-info/dependency_links.txt +0 -0
  117. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore.egg-info/entry_points.txt +0 -0
  118. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore.egg-info/requires.txt +0 -0
  119. {hexcore-1.7.0 → hexcore-1.8.0}/hexcore.egg-info/top_level.txt +0 -0
  120. {hexcore-1.7.0/hexcore/infrastructure/repositories/orms → hexcore-1.8.0/scripts}/__init__.py +0 -0
  121. {hexcore-1.7.0 → hexcore-1.8.0}/setup.cfg +0 -0
  122. {hexcore-1.7.0 → hexcore-1.8.0}/tests/test_basic.py +0 -0
@@ -0,0 +1 @@
1
+ recursive-include hexcore *.pyi py.typed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 1.7.0
3
+ Version: 1.8.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
@@ -49,6 +49,57 @@ Este repositorio cuenta con un conjunto de skills adicionales para extender y pe
49
49
  pip install hexcore
50
50
  ```
51
51
 
52
+ ## Templates de Proyecto (CLI)
53
+
54
+ HexCore incluye templates base para bootstrap de proyectos:
55
+
56
+ ```sh
57
+ hexcore init mi_proyecto --template hexagonal
58
+ hexcore init mi_proyecto --template vertical-slice
59
+ ```
60
+
61
+ - `hexagonal`: crea `src/domain`, `src/application`, `src/infrastructure`.
62
+ - `vertical-slice`: crea `src/features`, `src/shared/domain`, `src/shared/application`, `src/shared/infrastructure`.
63
+
64
+ En ambos templates se generan:
65
+ - `config.py` en raíz con `repository_discovery_paths` de ejemplo.
66
+ - estructura de migraciones con Alembic.
67
+
68
+ ---
69
+
70
+ ## Configuración v2 (Folder-Agnostic)
71
+
72
+ Desde v2, HexCore usa configuración explícita y no aplica fallback implícito para descubrir repositorios.
73
+
74
+ ### 1. Configuración visible en raíz
75
+
76
+ Define un archivo `config.py` en la raíz del proyecto:
77
+
78
+ ```python
79
+ from hexcore.config import ServerConfig
80
+
81
+ config = ServerConfig(
82
+ repository_discovery_paths={
83
+ "myapp.features.users.infrastructure.repositories",
84
+ "myapp.features.billing.infrastructure.repositories",
85
+ }
86
+ )
87
+ ```
88
+
89
+ ### 2. Prioridad para cargar configuración
90
+
91
+ `LazyConfig` resuelve módulos en este orden:
92
+
93
+ 1. `HEXCORE_CONFIG_MODULE`
94
+ 2. `HEXCORE_CONFIG_MODULES` (lista separada por comas)
95
+ 3. módulos configurados por `LazyConfig.set_config_modules(...)`
96
+ 4. `config` por defecto (raíz del proyecto)
97
+
98
+ ### 3. Regla de discovery en v2
99
+
100
+ - Si `repository_discovery_paths` está vacío, no se cargan módulos de repositorios.
101
+ - UoW falla con error explícito para evitar comportamiento ambiguo.
102
+
52
103
  ---
53
104
 
54
105
  ## Pautas de Colaboración
@@ -26,6 +26,57 @@ Este repositorio cuenta con un conjunto de skills adicionales para extender y pe
26
26
  pip install hexcore
27
27
  ```
28
28
 
29
+ ## Templates de Proyecto (CLI)
30
+
31
+ HexCore incluye templates base para bootstrap de proyectos:
32
+
33
+ ```sh
34
+ hexcore init mi_proyecto --template hexagonal
35
+ hexcore init mi_proyecto --template vertical-slice
36
+ ```
37
+
38
+ - `hexagonal`: crea `src/domain`, `src/application`, `src/infrastructure`.
39
+ - `vertical-slice`: crea `src/features`, `src/shared/domain`, `src/shared/application`, `src/shared/infrastructure`.
40
+
41
+ En ambos templates se generan:
42
+ - `config.py` en raíz con `repository_discovery_paths` de ejemplo.
43
+ - estructura de migraciones con Alembic.
44
+
45
+ ---
46
+
47
+ ## Configuración v2 (Folder-Agnostic)
48
+
49
+ Desde v2, HexCore usa configuración explícita y no aplica fallback implícito para descubrir repositorios.
50
+
51
+ ### 1. Configuración visible en raíz
52
+
53
+ Define un archivo `config.py` en la raíz del proyecto:
54
+
55
+ ```python
56
+ from hexcore.config import ServerConfig
57
+
58
+ config = ServerConfig(
59
+ repository_discovery_paths={
60
+ "myapp.features.users.infrastructure.repositories",
61
+ "myapp.features.billing.infrastructure.repositories",
62
+ }
63
+ )
64
+ ```
65
+
66
+ ### 2. Prioridad para cargar configuración
67
+
68
+ `LazyConfig` resuelve módulos en este orden:
69
+
70
+ 1. `HEXCORE_CONFIG_MODULE`
71
+ 2. `HEXCORE_CONFIG_MODULES` (lista separada por comas)
72
+ 3. módulos configurados por `LazyConfig.set_config_modules(...)`
73
+ 4. `config` por defecto (raíz del proyecto)
74
+
75
+ ### 3. Regla de discovery en v2
76
+
77
+ - Si `repository_discovery_paths` está vacío, no se cargan módulos de repositorios.
78
+ - UoW falla con error explícito para evitar comportamiento ambiguo.
79
+
29
80
  ---
30
81
 
31
82
  ## Pautas de Colaboración
@@ -0,0 +1,23 @@
1
+ """
2
+ Submódulo de DTOs base para la aplicación.
3
+ """
4
+
5
+ from .base import DTO
6
+ from .query import (
7
+ FilterConditionDTO,
8
+ FilterOperator,
9
+ QueryRequestDTO,
10
+ QueryResponseDTO,
11
+ SortConditionDTO,
12
+ SortDirection,
13
+ )
14
+
15
+ __all__ = [
16
+ "DTO",
17
+ "FilterOperator",
18
+ "SortDirection",
19
+ "FilterConditionDTO",
20
+ "SortConditionDTO",
21
+ "QueryRequestDTO",
22
+ "QueryResponseDTO",
23
+ ]
@@ -0,0 +1,66 @@
1
+ import enum
2
+ import typing as t
3
+
4
+ from pydantic import Field
5
+
6
+ from .base import DTO
7
+
8
+
9
+ class FilterOperator(str, enum.Enum):
10
+ EQ = "eq"
11
+ NE = "ne"
12
+ GT = "gt"
13
+ GTE = "gte"
14
+ LT = "lt"
15
+ LTE = "lte"
16
+ IN = "in"
17
+ NOT_IN = "not_in"
18
+ CONTAINS = "contains"
19
+ STARTSWITH = "startswith"
20
+ ENDSWITH = "endswith"
21
+ IS_NULL = "is_null"
22
+
23
+
24
+ class SortDirection(str, enum.Enum):
25
+ ASC = "asc"
26
+ DESC = "desc"
27
+
28
+
29
+ class FilterConditionDTO(DTO):
30
+ field: str = Field(min_length=1)
31
+ operator: FilterOperator = FilterOperator.EQ
32
+ value: t.Any = None
33
+
34
+
35
+ class SortConditionDTO(DTO):
36
+ field: str = Field(min_length=1)
37
+ direction: SortDirection = SortDirection.ASC
38
+
39
+
40
+ def _empty_search_fields() -> list[str]:
41
+ return []
42
+
43
+
44
+ def _empty_filters() -> list[FilterConditionDTO]:
45
+ return []
46
+
47
+
48
+ def _empty_sort() -> list[SortConditionDTO]:
49
+ return []
50
+
51
+
52
+ class QueryRequestDTO(DTO):
53
+ limit: int = Field(default=50, ge=1)
54
+ offset: int = Field(default=0, ge=0)
55
+ search: str | None = None
56
+ search_fields: list[str] = Field(default_factory=_empty_search_fields)
57
+ filters: list[FilterConditionDTO] = Field(default_factory=_empty_filters)
58
+ sort: list[SortConditionDTO] = Field(default_factory=_empty_sort)
59
+
60
+
61
+ class QueryResponseDTO(DTO):
62
+ items: list[t.Any] = Field(default_factory=list)
63
+ total: int = 0
64
+ limit: int = 0
65
+ offset: int = 0
66
+ has_next: bool = False
@@ -0,0 +1,13 @@
1
+ """
2
+ Submódulo de casos de uso base para la aplicación.
3
+ """
4
+
5
+ from .base import UseCase
6
+ from .query import ListEntitiesUseCase, QueryEntitiesUseCase, SearchEntitiesUseCase
7
+
8
+ __all__ = [
9
+ "UseCase",
10
+ "QueryEntitiesUseCase",
11
+ "ListEntitiesUseCase",
12
+ "SearchEntitiesUseCase",
13
+ ]
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ from hexcore.application.dtos.query import QueryRequestDTO, QueryResponseDTO
6
+ from hexcore.domain.base import BaseEntity
7
+ from hexcore.domain.repositories import IBaseRepository
8
+ from hexcore.domain.services import BaseDomainService
9
+
10
+ from .base import UseCase
11
+
12
+ T = t.TypeVar("T", bound=BaseEntity)
13
+
14
+
15
+ class QueryEntitiesUseCase(UseCase[QueryRequestDTO, QueryResponseDTO], t.Generic[T]):
16
+ def __init__(
17
+ self,
18
+ repository: IBaseRepository[T],
19
+ service: BaseDomainService | None = None,
20
+ ) -> None:
21
+ self.repository = repository
22
+ self.service = service or BaseDomainService()
23
+
24
+ async def execute(self, command: QueryRequestDTO) -> QueryResponseDTO:
25
+ return await self.service.list_entities(self.repository, command)
26
+
27
+
28
+ class ListEntitiesUseCase(QueryEntitiesUseCase[T]):
29
+ async def execute(self, command: QueryRequestDTO) -> QueryResponseDTO:
30
+ command.search = None
31
+ command.search_fields = []
32
+ return await super().execute(command)
33
+
34
+
35
+ class SearchEntitiesUseCase(QueryEntitiesUseCase[T]):
36
+ async def execute(self, command: QueryRequestDTO) -> QueryResponseDTO:
37
+ return await super().execute(command)
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
  import importlib
3
+ import os
3
4
  import typing as t
4
- from pydantic import BaseModel, ConfigDict
5
+ from pydantic import BaseModel, ConfigDict, Field
5
6
  from pathlib import Path
6
7
  from hexcore.infrastructure.cache import ICache
7
8
  from hexcore.domain.events import IEventDispatcher
@@ -53,25 +54,74 @@ class ServerConfig(BaseModel):
53
54
  # Event Dispatcher
54
55
  event_dispatcher: IEventDispatcher = InMemoryEventDispatcher()
55
56
 
57
+ # Repository Discovery
58
+ # v2 (breaking): discovery explicito y folder-agnostic.
59
+ # Si se deja vacio, no se autoloadearan modulos de repositorios.
60
+ repository_discovery_paths: set[str] = Field(default_factory=set)
61
+
56
62
 
57
63
  class LazyConfig:
58
64
  """
59
- Loader de configuración flexible.
60
- Busca una variable 'config' (instancia de ServerConfig) o una clase 'ServerConfig' en los módulos personalizados.
61
- Si no la encuentra, usa la configuración base del kernel.
65
+ Loader de configuración flexible y agnóstico de estructura de carpetas.
66
+
67
+ Prioridad de resolución:
68
+ 1) Variable de entorno HEXCORE_CONFIG_MODULE (módulo único)
69
+ 2) Variable de entorno HEXCORE_CONFIG_MODULES (lista separada por comas)
70
+ 3) Lista configurada por set_config_modules(...)
71
+ 4) Valor por defecto: "config" (archivo config.py en la raíz del proyecto)
72
+
73
+ En cada módulo candidato se busca:
74
+ - atributo `config` (instancia o clase derivada de ServerConfig)
75
+ - o clase `ServerConfig` derivada de la base.
62
76
 
63
- IMPORTANTE: La configuración personalizada debe estar en un módulo llamado 'config' en src.domain
77
+ Si no se encuentra nada válido, usa ServerConfig() por defecto.
64
78
 
65
79
  """
66
80
 
67
81
  _imported_config: t.Optional[ServerConfig] = None
82
+ _config_modules: tuple[str, ...] = ("config",)
83
+
84
+ @classmethod
85
+ def set_config_modules(cls, modules: t.Iterable[str]) -> None:
86
+ """Define módulos candidatos para resolver configuración personalizada."""
87
+ normalized_modules = tuple(
88
+ module_name.strip() for module_name in modules if str(module_name).strip()
89
+ )
90
+ cls._config_modules = normalized_modules
91
+ cls._imported_config = None
92
+
93
+ @classmethod
94
+ def clear_cache(cls) -> None:
95
+ """Limpia la configuración cacheada para forzar nueva resolución."""
96
+ cls._imported_config = None
97
+
98
+ @classmethod
99
+ def _iter_config_module_candidates(cls) -> tuple[str, ...]:
100
+ env_single_module = os.getenv("HEXCORE_CONFIG_MODULE", "").strip()
101
+ if env_single_module:
102
+ return (env_single_module,)
103
+
104
+ env_modules_raw = os.getenv("HEXCORE_CONFIG_MODULES", "").strip()
105
+ if env_modules_raw:
106
+ env_modules = tuple(
107
+ module_name.strip()
108
+ for module_name in env_modules_raw.split(",")
109
+ if module_name.strip()
110
+ )
111
+ if env_modules:
112
+ return env_modules
113
+
114
+ if cls._config_modules:
115
+ return cls._config_modules
116
+
117
+ return ("config",)
68
118
 
69
119
  @classmethod
70
120
  def get_config(cls) -> ServerConfig:
71
121
  if cls._imported_config is not None:
72
122
  return cls._imported_config
73
123
  # Intenta importar la config personalizada
74
- for modpath in ("config", "src.domain.config"):
124
+ for modpath in cls._iter_config_module_candidates():
75
125
  try:
76
126
  mod = importlib.import_module(modpath)
77
127
  config_instance = getattr(mod, "config", None)
@@ -99,4 +149,4 @@ class LazyConfig:
99
149
  return cls._imported_config
100
150
 
101
151
 
102
- # Esto es solo para disparar el Workflow pra subir la ultima version a PyPI, por favor ignora este comentario
152
+ # Esto es solo para disparar el Workflow pra subir la ultima version a PyPI, por favor ignora este comentario
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import typing as t
5
+
6
+ from hexcore.application.dtos.query import (
7
+ FilterConditionDTO,
8
+ FilterOperator,
9
+ QueryRequestDTO,
10
+ QueryResponseDTO,
11
+ SortConditionDTO,
12
+ SortDirection,
13
+ )
14
+ from hexcore.domain.base import BaseEntity
15
+ from hexcore.domain.events import IEventDispatcher
16
+ from hexcore.domain.repositories import IBaseRepository
17
+ from hexcore.config import LazyConfig
18
+
19
+
20
+ T = t.TypeVar("T", bound=BaseEntity)
21
+
22
+
23
+ class BaseDomainService:
24
+ def __init__(
25
+ self,
26
+ event_dispatcher: IEventDispatcher = LazyConfig().get_config().event_dispatcher,
27
+ ) -> None:
28
+ self.config = LazyConfig.get_config()
29
+ self.event_dispatcher = event_dispatcher
30
+
31
+ async def list_entities(
32
+ self,
33
+ repository: IBaseRepository[T],
34
+ query: QueryRequestDTO,
35
+ ) -> QueryResponseDTO:
36
+ query_all = getattr(repository, "query_all", None)
37
+ if callable(query_all):
38
+ entities, total = await t.cast(
39
+ t.Callable[[QueryRequestDTO], t.Awaitable[tuple[list[T], int]]],
40
+ query_all,
41
+ )(query)
42
+ has_next = query.offset + len(entities) < total
43
+ return QueryResponseDTO(
44
+ items=entities,
45
+ total=total,
46
+ limit=query.limit,
47
+ offset=query.offset,
48
+ has_next=has_next,
49
+ )
50
+
51
+ entities = await repository.list_all(limit=None, offset=0)
52
+ return self.query_entities(entities, query)
53
+
54
+ def query_entities(
55
+ self,
56
+ entities: t.Sequence[T],
57
+ query: QueryRequestDTO,
58
+ ) -> QueryResponseDTO:
59
+ results: list[T] = list(entities)
60
+ results = self._apply_search(results, query.search, query.search_fields)
61
+ results = self._apply_filters(results, query.filters)
62
+ results = self._apply_sort(results, query.sort)
63
+
64
+ total = len(results)
65
+ paginated = results[query.offset : query.offset + query.limit]
66
+ has_next = query.offset + len(paginated) < total
67
+
68
+ return QueryResponseDTO(
69
+ items=paginated,
70
+ total=total,
71
+ limit=query.limit,
72
+ offset=query.offset,
73
+ has_next=has_next,
74
+ )
75
+
76
+ def _apply_search(
77
+ self,
78
+ entities: list[T],
79
+ search_text: str | None,
80
+ search_fields: list[str],
81
+ ) -> list[T]:
82
+ if not search_text:
83
+ return entities
84
+
85
+ normalized_query = search_text.lower().strip()
86
+ if not normalized_query:
87
+ return entities
88
+
89
+ fields = search_fields or self._infer_search_fields(entities)
90
+ if not fields:
91
+ return entities
92
+
93
+ matched: list[T] = []
94
+ for entity in entities:
95
+ for field_name in fields:
96
+ value = getattr(entity, field_name, None)
97
+ if value is None:
98
+ continue
99
+ if normalized_query in str(value).lower():
100
+ matched.append(entity)
101
+ break
102
+ return matched
103
+
104
+ def _infer_search_fields(self, entities: list[T]) -> list[str]:
105
+ if not entities:
106
+ return []
107
+
108
+ sample = entities[0]
109
+ sample_dict = sample.model_dump()
110
+ return [
111
+ field_name
112
+ for field_name, value in sample_dict.items()
113
+ if isinstance(value, (str, int, float, bool, dt.date, dt.datetime))
114
+ ]
115
+
116
+ def _apply_filters(
117
+ self,
118
+ entities: list[T],
119
+ filters: list[FilterConditionDTO],
120
+ ) -> list[T]:
121
+ if not filters:
122
+ return entities
123
+
124
+ return [
125
+ entity
126
+ for entity in entities
127
+ if all(self._matches_filter(entity, condition) for condition in filters)
128
+ ]
129
+
130
+ def _matches_filter(
131
+ self,
132
+ entity: BaseEntity,
133
+ condition: FilterConditionDTO,
134
+ ) -> bool:
135
+ value = getattr(entity, condition.field, None)
136
+ operator = condition.operator
137
+ expected = condition.value
138
+
139
+ if operator == FilterOperator.IS_NULL:
140
+ return value is None
141
+
142
+ if operator == FilterOperator.EQ:
143
+ return value == expected
144
+ if operator == FilterOperator.NE:
145
+ return value != expected
146
+ if operator == FilterOperator.GT:
147
+ return self._safe_compare(value, expected, lambda a, b: a > b)
148
+ if operator == FilterOperator.GTE:
149
+ return self._safe_compare(value, expected, lambda a, b: a >= b)
150
+ if operator == FilterOperator.LT:
151
+ return self._safe_compare(value, expected, lambda a, b: a < b)
152
+ if operator == FilterOperator.LTE:
153
+ return self._safe_compare(value, expected, lambda a, b: a <= b)
154
+ if operator == FilterOperator.IN:
155
+ return self._in_operator(value, expected)
156
+ if operator == FilterOperator.NOT_IN:
157
+ return not self._in_operator(value, expected)
158
+ if operator == FilterOperator.CONTAINS:
159
+ return self._text_operator(value, expected, mode="contains")
160
+ if operator == FilterOperator.STARTSWITH:
161
+ return self._text_operator(value, expected, mode="startswith")
162
+ if operator == FilterOperator.ENDSWITH:
163
+ return self._text_operator(value, expected, mode="endswith")
164
+
165
+ return False
166
+
167
+ def _safe_compare(
168
+ self,
169
+ left: t.Any,
170
+ right: t.Any,
171
+ comparator: t.Callable[[t.Any, t.Any], bool],
172
+ ) -> bool:
173
+ if left is None or right is None:
174
+ return False
175
+ try:
176
+ return comparator(left, right)
177
+ except TypeError:
178
+ return False
179
+
180
+ def _in_operator(self, value: t.Any, expected: t.Any) -> bool:
181
+ if expected is None:
182
+ return False
183
+ if isinstance(expected, (str, bytes)):
184
+ return value == expected
185
+ if not isinstance(expected, (list, tuple, set, frozenset)):
186
+ return False
187
+ return value in expected
188
+
189
+ def _text_operator(self, value: t.Any, expected: t.Any, mode: str) -> bool:
190
+ if value is None or expected is None:
191
+ return False
192
+ left = str(value).lower()
193
+ right = str(expected).lower()
194
+ if mode == "contains":
195
+ return right in left
196
+ if mode == "startswith":
197
+ return left.startswith(right)
198
+ if mode == "endswith":
199
+ return left.endswith(right)
200
+ return False
201
+
202
+ def _apply_sort(
203
+ self,
204
+ entities: list[T],
205
+ sort_conditions: list[SortConditionDTO],
206
+ ) -> list[T]:
207
+ if not sort_conditions:
208
+ return entities
209
+
210
+ ordered = list(entities)
211
+ for condition in reversed(sort_conditions):
212
+ reverse = condition.direction == SortDirection.DESC
213
+ ordered.sort(
214
+ key=lambda item: self._safe_sort_value(getattr(item, condition.field, None)),
215
+ reverse=reverse,
216
+ )
217
+ return ordered
218
+
219
+ def _safe_sort_value(self, value: t.Any) -> tuple[int, int, t.Any]:
220
+ if value is None:
221
+ return (1, 0, None)
222
+ if isinstance(value, bool):
223
+ return (0, 0, int(value))
224
+ if isinstance(value, (int, float)):
225
+ return (0, 1, float(value))
226
+ if isinstance(value, dt.datetime):
227
+ return (0, 2, value.timestamp())
228
+ if isinstance(value, dt.date):
229
+ return (0, 3, value.toordinal())
230
+ if isinstance(value, str):
231
+ return (0, 4, value.casefold())
232
+ return (0, 5, str(value))
@@ -0,0 +1,3 @@
1
+ from .utils import build_query_endpoint, register_query_endpoint
2
+
3
+ __all__ = ["build_query_endpoint", "register_query_endpoint"]