sincpro-framework 3.7.0__tar.gz → 3.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/PKG-INFO +4 -2
  2. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/pyproject.toml +3 -1
  3. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/bus.py +21 -17
  4. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/bus.pyi +2 -2
  5. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/__init__.py +7 -5
  6. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/criteria.py +98 -45
  7. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/entity.py +45 -11
  8. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/entity_collection.py +3 -3
  9. sincpro_framework-3.7.2/sincpro_framework/ddd/events.py +142 -0
  10. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/memory_repository.py +72 -43
  11. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/query.py +41 -1
  12. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/relations.py +1 -1
  13. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/events/queue.py +3 -2
  14. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/events/subscriber.py +2 -9
  15. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/introspection/inspector.py +5 -8
  16. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ioc.py +41 -12
  17. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/data_mapper.py +2 -2
  18. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/database.py +1 -1
  19. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/repository.py +114 -89
  20. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/sql_translator.py +9 -0
  21. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/sincpro_abstractions.py +1 -1
  22. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/sincpro_abstractions.pyi +1 -1
  23. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/use_bus.py +33 -1
  24. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/use_bus.pyi +14 -6
  25. sincpro_framework-3.7.0/sincpro_framework/ddd/events.py +0 -59
  26. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/LICENSE.md +0 -0
  27. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/README.md +0 -0
  28. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/__init__.py +0 -0
  29. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/aio/__init__.py +0 -0
  30. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/aio/bus.py +0 -0
  31. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/aio/bus.pyi +0 -0
  32. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/conf/sincpro_framework_conf.yml +0 -0
  33. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/__init__.py +0 -0
  34. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/framework_context.py +0 -0
  35. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/framework_context.pyi +0 -0
  36. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/framework_context_consumer.py +0 -0
  37. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/mixin.py +0 -0
  38. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/thread_context_bus.py +0 -0
  39. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/evaluate.py +0 -0
  40. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/exceptions.py +0 -0
  41. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/model_meta.py +0 -0
  42. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/pagination.py +0 -0
  43. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/repository.py +0 -0
  44. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/value_object.py +0 -0
  45. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/deps.py +0 -0
  46. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/__init__.py +0 -0
  47. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/catalog.py +0 -0
  48. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/const.py +0 -0
  49. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/json_utils.py +0 -0
  50. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/mcp/__init__.py +0 -0
  51. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/mcp/entrypoint.py +0 -0
  52. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/mcp/mcp.py +0 -0
  53. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/rpc/__init__.py +0 -0
  54. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/rpc/entrypoint.py +0 -0
  55. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/rpc/jrpc.py +0 -0
  56. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/scalar_executor.py +0 -0
  57. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/error_handler.py +0 -0
  58. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/events/__init__.py +0 -0
  59. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/events/publisher.py +0 -0
  60. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/exceptions.py +0 -0
  61. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/introspection/__init__.py +0 -0
  62. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/middleware.py +0 -0
  63. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/__init__.py +0 -0
  64. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/api.py +0 -0
  65. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/domain.py +0 -0
  66. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/errors/__init__.py +0 -0
  67. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/errors/record_error.py +0 -0
  68. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/errors/setup.py +0 -0
  69. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/registry.py +0 -0
  70. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/__init__.py +0 -0
  71. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/setup.py +0 -0
  72. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/span_context.py +0 -0
  73. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/span_error.py +0 -0
  74. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/span_execution.py +0 -0
  75. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/__init__.py +0 -0
  76. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/__init__.py +0 -0
  77. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/custom_fields.py +0 -0
  78. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/model_introspection.py +0 -0
  79. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/observability.py +0 -0
  80. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/relation_resolver.py +0 -0
  81. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/py.typed +0 -0
  82. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/sincpro_conf.py +0 -0
  83. {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/sincpro_logger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sincpro-framework
3
- Version: 3.7.0
3
+ Version: 3.7.2
4
4
  Summary: Sincpro framework to use DDD, Clean architecture, Hexagonal architecture
5
5
  License: MIT
6
6
  License-File: LICENSE.md
@@ -12,12 +12,14 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
+ Provides-Extra: duckdb
15
16
  Provides-Extra: mcp
16
17
  Provides-Extra: opentelemetry
17
18
  Provides-Extra: rpc
18
19
  Provides-Extra: sentry
19
20
  Provides-Extra: sqlalchemy
20
21
  Requires-Dist: dependency-injector (>=4.49.1,<5.0.0)
22
+ Requires-Dist: duckdb-engine (>=0.17.0,<0.18.0) ; extra == "duckdb"
21
23
  Requires-Dist: fastmcp (>=3.0,<4) ; extra == "mcp"
22
24
  Requires-Dist: opentelemetry-api (>=1.20) ; extra == "opentelemetry"
23
25
  Requires-Dist: opentelemetry-exporter-otlp-proto-grpc (>=1.20) ; extra == "opentelemetry"
@@ -27,7 +29,7 @@ Requires-Dist: pydantic (>=2.13.5,<3.0.0)
27
29
  Requires-Dist: pyyaml (>=6.0.1)
28
30
  Requires-Dist: sentry-sdk (>=2.0) ; extra == "sentry"
29
31
  Requires-Dist: sincpro-log (>=1.2.1,<2.0.0)
30
- Requires-Dist: sqlalchemy (>=2.0) ; extra == "sqlalchemy"
32
+ Requires-Dist: sqlalchemy (>=2.0) ; extra == "sqlalchemy" or extra == "duckdb"
31
33
  Requires-Dist: starlette (>=1.0) ; extra == "rpc"
32
34
  Requires-Dist: uvicorn (>=0.35) ; extra == "rpc"
33
35
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "sincpro-framework"
3
- version = "3.7.0"
3
+ version = "3.7.2"
4
4
  description = "Sincpro framework to use DDD, Clean architecture, Hexagonal architecture"
5
5
  authors = ["Gutierrez Andres <andru1236@gmail.com>"]
6
6
  readme = "README.md"
@@ -27,6 +27,7 @@ fastmcp = {version = ">=3.0,<4", optional = true}
27
27
  starlette = {version = ">=1.0", optional = true}
28
28
  uvicorn = {version = ">=0.35", optional = true}
29
29
  sqlalchemy = {version = ">=2.0", optional = true}
30
+ duckdb-engine = {version = "^0.17.0", optional = true}
30
31
 
31
32
  [tool.poetry.extras]
32
33
  opentelemetry = [
@@ -39,6 +40,7 @@ sentry = ["sentry-sdk"]
39
40
  mcp = ["fastmcp"]
40
41
  rpc = ["starlette", "uvicorn"]
41
42
  sqlalchemy = ["sqlalchemy"]
43
+ duckdb = ["sqlalchemy", "duckdb-engine"]
42
44
 
43
45
  [tool.poetry.group.dev.dependencies]
44
46
  isort = "^9.0.1"
@@ -22,27 +22,28 @@ class FeatureBus(Bus):
22
22
  logger_bus: Logger = logger, # type: ignore[assignment]
23
23
  observability: Optional[Observability] = None,
24
24
  ):
25
- self.feature_registry: Dict[str, Feature] = dict()
25
+ self.feature_registry: Dict[type, Feature] = dict()
26
26
  self.handle_error: Optional[Callable] = None
27
27
  self.logger: Logger = logger_bus or logger # type: ignore[assignment]
28
28
  self.observability = observability or Observability()
29
29
 
30
30
  def register_feature(self, dto: Type[DataTransferObject], feature: Feature) -> bool:
31
31
  """Register a feature to the bus"""
32
- if dto.__name__ in self.feature_registry:
32
+ if dto in self.feature_registry:
33
33
  raise DTOAlreadyRegistered(
34
34
  f"Data transfer object {dto.__name__} is already registered"
35
35
  )
36
36
 
37
37
  self.logger.info(f"Registering feature [{dto.__name__}]")
38
- self.feature_registry[dto.__name__] = feature
38
+ self.feature_registry[dto] = feature
39
39
  return True
40
40
 
41
41
  def execute( # type: ignore[override]
42
42
  self, dto: TypeDTO, return_type: Type[TypeDTOResponse] | None = None
43
43
  ) -> TypeDTOResponse | None:
44
44
  """Execute a feature, and handle error if exists error handler"""
45
- dto_name = dto.__class__.__name__
45
+ dto_type = dto.__class__
46
+ dto_name = dto_type.__name__
46
47
 
47
48
  with self.observability.span(dto_name, "feature") as span:
48
49
  if is_logger_in_debug() or self.log_after_execution:
@@ -50,7 +51,7 @@ class FeatureBus(Bus):
50
51
  self.logger.debug(f"{dto_name}({dto})")
51
52
 
52
53
  try:
53
- response = self.feature_registry[dto.__class__.__name__].execute(dto)
54
+ response = self.feature_registry[dto_type].execute(dto)
54
55
  if response:
55
56
  self.logger.debug(
56
57
  f"Feature response {response.__class__.__name__}({response})",
@@ -74,7 +75,7 @@ class ApplicationServiceBus(Bus):
74
75
  logger_bus: Logger = logger, # type: ignore[assignment]
75
76
  observability: Optional[Observability] = None,
76
77
  ):
77
- self.app_service_registry: Dict[str, ApplicationService] = dict()
78
+ self.app_service_registry: Dict[type, ApplicationService] = dict()
78
79
  self.handle_error: Optional[Callable] = None
79
80
  self.logger = logger_bus or logger
80
81
  self.observability = observability or Observability()
@@ -85,20 +86,21 @@ class ApplicationServiceBus(Bus):
85
86
  """Register an application service to the bus
86
87
  This method is not used directly, the decorator inject_app_service_to_bus is used
87
88
  """
88
- if dto.__name__ in self.app_service_registry:
89
+ if dto in self.app_service_registry:
89
90
  raise DTOAlreadyRegistered(
90
91
  f"Data transfer object {dto.__name__} is already registered"
91
92
  )
92
93
 
93
94
  self.logger.info(f"Registering application service [{dto.__name__}]")
94
- self.app_service_registry[dto.__name__] = app_service
95
+ self.app_service_registry[dto] = app_service
95
96
  return True
96
97
 
97
98
  def execute( # type: ignore[override]
98
99
  self, dto: TypeDTO, return_type: Type[TypeDTOResponse] | None = None
99
100
  ) -> TypeDTOResponse | None:
100
101
  """Execute an application service, and handle error if exists error handler"""
101
- dto_name = dto.__class__.__name__
102
+ dto_type = dto.__class__
103
+ dto_name = dto_type.__name__
102
104
 
103
105
  with self.observability.span(dto_name, "application_service") as span:
104
106
  if is_logger_in_debug() or self.log_after_execution:
@@ -106,7 +108,7 @@ class ApplicationServiceBus(Bus):
106
108
  self.logger.debug(f"{dto_name}({dto})")
107
109
 
108
110
  try:
109
- response = self.app_service_registry[dto.__class__.__name__].execute(dto)
111
+ response = self.app_service_registry[dto_type].execute(dto)
110
112
  if response:
111
113
  self.logger.debug(
112
114
  f"Application service response {response.__class__.__name__}({response})"
@@ -153,11 +155,12 @@ class FrameworkBus(Bus):
153
155
 
154
156
  intersection_dtos = registered_features.intersection(registered_app_services)
155
157
  if intersection_dtos:
158
+ intersection_names = {dto.__name__ for dto in intersection_dtos}
156
159
  self.logger.error(
157
- f"Features and app services have the same name: {registered_features.intersection(registered_app_services)}",
160
+ f"Features and app services have the same DTO: {intersection_names}",
158
161
  )
159
162
  raise DTOAlreadyRegistered(
160
- f"Data transfer object {intersection_dtos} is present in application services and features, Change "
163
+ f"Data transfer object {intersection_names} is present in application services and features, Change "
161
164
  f"the name of the feature or create another framework instance to handle in doupled wat"
162
165
  )
163
166
 
@@ -170,21 +173,22 @@ class FrameworkBus(Bus):
170
173
  if the DTO is present in the app service bus
171
174
  otherwise will execute the DTO in the feature bus
172
175
  """
173
- dto_name = dto.__class__.__name__
176
+ dto_type = dto.__class__
177
+ dto_name = dto_type.__name__
174
178
  try:
175
179
  if (
176
- dto_name in self.app_service_bus.app_service_registry
177
- and dto_name in self.feature_bus.feature_registry
180
+ dto_type in self.app_service_bus.app_service_registry
181
+ and dto_type in self.feature_bus.feature_registry
178
182
  ):
179
183
  raise DTOAlreadyRegistered(
180
184
  f"Data transfer object {dto_name} is present in application services and features, Change the "
181
185
  f"name of the feature or create another framework instance to handle in doupled wat"
182
186
  )
183
- if dto_name in self.feature_bus.feature_registry:
187
+ if dto_type in self.feature_bus.feature_registry:
184
188
  response = self.feature_bus.execute(dto)
185
189
  return response
186
190
 
187
- if dto_name in self.app_service_bus.app_service_registry:
191
+ if dto_type in self.app_service_bus.app_service_registry:
188
192
  response = self.app_service_bus.execute(dto)
189
193
  return response
190
194
 
@@ -21,7 +21,7 @@ class FeatureBus(Bus):
21
21
  """
22
22
 
23
23
  log_after_execution: bool
24
- feature_registry: Dict[str, Feature]
24
+ feature_registry: Dict[type, Feature]
25
25
  handle_error: Optional[Callable[..., Any]]
26
26
  logger: LoggerProxy
27
27
  observability: Observability
@@ -52,7 +52,7 @@ class ApplicationServiceBus(Bus):
52
52
  """
53
53
 
54
54
  log_after_execution: bool
55
- app_service_registry: Dict[str, ApplicationService]
55
+ app_service_registry: Dict[type, ApplicationService]
56
56
  handle_error: Optional[Callable[..., Any]]
57
57
  logger: LoggerProxy
58
58
  observability: Observability
@@ -21,8 +21,8 @@ from sincpro_framework.ddd.criteria import (
21
21
  Specification,
22
22
  )
23
23
  from sincpro_framework.ddd.entity import (
24
- Archivable,
25
- Audited,
24
+ ArchivableMixin,
25
+ AuditedMixin,
26
26
  Entity,
27
27
  Translated,
28
28
  new_entity_id,
@@ -30,7 +30,7 @@ from sincpro_framework.ddd.entity import (
30
30
  )
31
31
  from sincpro_framework.ddd.entity_collection import Changes, Count, Dropped, EntityCollection
32
32
  from sincpro_framework.ddd.evaluate import matches
33
- from sincpro_framework.ddd.events import DomainEvent
33
+ from sincpro_framework.ddd.events import DomainEvent, EventStatus, TrackableMixin
34
34
  from sincpro_framework.ddd.exceptions import (
35
35
  ContractViolation,
36
36
  DomainError,
@@ -48,8 +48,8 @@ from sincpro_framework.ddd.repository import Repository
48
48
  from sincpro_framework.ddd.value_object import ValueObject
49
49
 
50
50
  __all__ = [
51
- "Archivable",
52
- "Audited",
51
+ "ArchivableMixin",
52
+ "AuditedMixin",
53
53
  "Bucket",
54
54
  "BusResolver",
55
55
  "EntityCollection",
@@ -63,6 +63,7 @@ __all__ = [
63
63
  "Dropped",
64
64
  "DuplicateAggregate",
65
65
  "Entity",
66
+ "EventStatus",
66
67
  "FieldMeta",
67
68
  "FieldType",
68
69
  "InvalidCriteria",
@@ -84,6 +85,7 @@ __all__ = [
84
85
  "Specification",
85
86
  "RelationNotResolved",
86
87
  "StaleAggregate",
88
+ "TrackableMixin",
87
89
  "Translated",
88
90
  "ValueObject",
89
91
  "matches",
@@ -15,7 +15,7 @@ from decimal import Decimal
15
15
  from enum import StrEnum
16
16
  from typing import Any, Union, cast
17
17
 
18
- from pydantic import Field, JsonValue, RootModel, field_validator
18
+ from pydantic import Field, JsonValue, RootModel, field_validator, model_validator
19
19
 
20
20
  from sincpro_framework.ddd.exceptions import InvalidCriteria
21
21
  from sincpro_framework.ddd.pagination import Cursor, Pagination
@@ -209,47 +209,93 @@ def parse_order(raw: str) -> tuple[Sort, ...]:
209
209
  )
210
210
 
211
211
 
212
- FOLD_FUNCTIONS: tuple[str, ...] = ("sum", "avg", "min", "max", "count")
213
- """The aggregates a fold may ask for. A closed list, because the name reaches SQL: anything
214
- outside it is refused before a statement exists."""
212
+ MEASURE_FUNCTIONS: tuple[str, ...] = (
213
+ "sum",
214
+ "avg",
215
+ "min",
216
+ "max",
217
+ "count",
218
+ "count_distinct",
219
+ "percentile",
220
+ )
221
+ """The five functions a measure may ask for. A closed list, because the name reaches SQL:
222
+ anything outside it is refused before a statement exists. Adding one is a change here, not a
223
+ string a caller can invent."""
224
+
215
225
 
226
+ class Measure(DataTransferObject):
227
+ """One number a group answers: a function over a field, under a name the caller chose.
216
228
 
217
- class Fold(DataTransferObject):
218
- """One number folded out of a bucket's rows.
229
+ in {"function": "sum", "field": "row_count"}
230
+ out Measure(function='sum', field='row_count') → sum(row_count) AS <name>
219
231
 
220
- in ["sum", "row_count"]
221
- out Fold(function='sum', field='row_count') → sum(row_count) AS <name>
232
+ **Named parts and not `"sum:row_count"`**, so nobody has to split a string in whatever
233
+ language is reading this — the same reason a condition travels as `{field, operator,
234
+ value}`. The pair `["sum", "row_count"]` is read as well, because writing one by hand in
235
+ Python is a pair; what a caller sends over a wire is the object.
222
236
 
223
- A pair and not `"sum:row_count"`, so nobody has to split a string — the same reason a
224
- condition is written as a triple.
237
+ It is called a measure and not an aggregate on purpose: in this codebase an *aggregate* is
238
+ the DDD root — what `Meta.aggregate` names — and two meanings for one word in the same
239
+ sentence is how a reader loses an afternoon.
225
240
  """
226
241
 
227
242
  function: str
228
243
  field: str
229
244
 
245
+ argument: float | None = None
246
+ """What the function needs besides a column, when it needs anything.
247
+
248
+ Only `percentile` does: the fraction it cuts at, `0.5` for the median and `0.95` for a P95.
249
+ Every other measure refuses one rather than ignoring it, because a number that is quietly
250
+ dropped is a report that is quietly wrong."""
251
+
230
252
  @field_validator("function")
231
253
  @classmethod
232
254
  def _one_of_the_known_aggregates(cls, function: str) -> str:
233
- if function not in FOLD_FUNCTIONS:
255
+ if function not in MEASURE_FUNCTIONS:
234
256
  raise InvalidCriteria(
235
- f"'{function}' is not a fold; use one of {', '.join(FOLD_FUNCTIONS)}"
257
+ f"'{function}' is not a measure; use one of {', '.join(MEASURE_FUNCTIONS)}"
236
258
  )
237
259
  return function
238
260
 
261
+ @model_validator(mode="after")
262
+ def _the_argument_belongs_to_the_function(self) -> "Measure":
263
+ """`percentile` needs its fraction; nothing else takes one.
264
+
265
+ >>> Measure(function="percentile", field="debit", argument=0.95)
266
+ Measure(function='percentile', field='debit', argument=0.95)
267
+ >>> Measure(function="sum", field="debit", argument=0.95)
268
+ InvalidCriteria: 'sum' takes no argument
269
+ """
270
+ if self.function == "percentile":
271
+ if self.argument is None or not 0 < self.argument < 1:
272
+ raise InvalidCriteria(
273
+ "a percentile is cut at a fraction between 0 and 1, e.g. 0.5 for the "
274
+ f"median; got {self.argument!r}"
275
+ )
276
+ elif self.argument is not None:
277
+ raise InvalidCriteria(f"'{self.function}' takes no argument")
278
+ return self
279
+
239
280
  @classmethod
240
- def read(cls, written: Any) -> "Fold":
241
- """A fold as it comes in JSON: the pair, and nothing else.
281
+ def read(cls, written: Any) -> "Measure":
282
+ """A measure as it comes in: the object on the wire, or the pair written by hand.
242
283
 
243
- >>> Fold.read(["sum", "row_count"])
244
- Fold(function='sum', field='row_count')
284
+ >>> Measure.read({"function": "sum", "field": "row_count"})
285
+ Measure(function='sum', field='row_count')
286
+ >>> Measure.read(["sum", "row_count"])
287
+ Measure(function='sum', field='row_count')
245
288
  """
246
- if isinstance(written, Fold):
289
+ if isinstance(written, Measure):
247
290
  return written
248
291
  if isinstance(written, dict):
249
292
  return cls(**written)
250
- if not isinstance(written, list) or len(written) != 2 or not all(written):
293
+ # A tuple as much as a list: a pair written in Python is `("sum", "row_count")` and
294
+ # the same pair read from JSON is a list. They mean the same measure.
295
+ if not isinstance(written, (list, tuple)) or len(written) != 2 or not all(written):
251
296
  raise InvalidCriteria(
252
- f"a fold is written [function, field], e.g. ['sum', 'row_count']; got {written!r}"
297
+ "a measure is written {'function': …, 'field': …}, or as the pair "
298
+ f"['sum', 'row_count']; got {written!r}"
253
299
  )
254
300
  return cls(function=written[0], field=written[1])
255
301
 
@@ -283,7 +329,7 @@ class Level(DataTransferObject):
283
329
  class Grouping(DataTransferObject):
284
330
  """How a result set is split: the levels, and the numbers folded out of every group.
285
331
 
286
- in {"by": ["produced_by", "registered_at:month"], "totals": {"filas": ["sum", "row_count"]}}
332
+ in {"by": ["produced_by", "registered_at:month"], "measures": {"filas": ["sum", "row_count"]}}
287
333
  out every level `by` names, each group counted and folded, carrying how to open it
288
334
 
289
335
  All the levels named are answered, one statement each: grouping by three fields is three
@@ -292,18 +338,24 @@ class Grouping(DataTransferObject):
292
338
  has to know how deep the whole tree is.
293
339
  """
294
340
 
295
- by: tuple[Level, ...] = ()
296
- totals: dict[str, Fold] = {}
341
+ group_by: tuple[Level, ...] = ()
342
+ """The levels the set is split by, one statement each."""
343
+
344
+ measures: dict[str, Measure] = {}
345
+ """The numbers every group answers, each under a name the caller chose."""
346
+
347
+ where_measures: Expression | None = None
348
+ """A filter over what the groups measured, not over the rows: `count > 10`, `debit > 1000`.
297
349
 
298
- having: Expression | None = None
299
- """A filter over what the groups fold, not over the rows: `count > 10`, `debit > 1000`.
300
- The names it may use are the ones in `totals` plus `count`; `where` filters the rows that
301
- make the groups, `having` filters the groups the rows made."""
350
+ The names it may use are the ones in `measures` plus `count`. `where` filters the rows that
351
+ make the groups; this filters the groups those rows made — which is why it is not called
352
+ `where`: it reads measures, not fields."""
302
353
 
303
354
  order: tuple[Sort, ...] = ()
304
- """How the groups of each level come back: by the level's own field, by a name in `totals`
355
+ """How the groups of each level come back: by the level's own field, by a name in `measures`
305
356
  or by `count`. Empty means by the level's field, ascending, which is how a list of groups
306
- reads when nobody asked for anything else."""
357
+ reads when nobody asked for anything else. It orders the GROUPS; `Criteria.order` orders
358
+ the rows, and with a page asked for it decides which rows each group shows first."""
307
359
 
308
360
  pagination: Pagination | None = None
309
361
  """How many groups of the FIRST level come back, and from where. `None`, the default, is
@@ -317,7 +369,7 @@ class Grouping(DataTransferObject):
317
369
  def paged(self) -> bool:
318
370
  """Whether only some of the groups were asked for.
319
371
 
320
- >>> Grouping(by=(Level(field="a"),)).paged
372
+ >>> Grouping(group_by=(Level(field="a"),)).paged
321
373
  False
322
374
  """
323
375
  return self.pagination is not None
@@ -329,14 +381,14 @@ class Grouping(DataTransferObject):
329
381
  >>> Grouping().asked
330
382
  False
331
383
  """
332
- return bool(self.by)
384
+ return bool(self.group_by)
333
385
 
334
386
 
335
387
  class Bucket(DataTransferObject):
336
388
  """One group: what its rows share, how many there are, and how to see them.
337
389
 
338
390
  out Bucket(field='produced_by', value=None, count=187,
339
- totals={'filas': 4012933}, criteria=Criteria(where=…))
391
+ measures={'filas': 4012933}, criteria=Criteria(where=…))
340
392
 
341
393
  `criteria` is the whole point. It is this reading plus «and this bucket's value», so
342
394
  opening the group is `self.repository.search(Dataset, bucket.criteria)` and nothing else — no client
@@ -356,7 +408,7 @@ class Bucket(DataTransferObject):
356
408
  field: str
357
409
  value: Any
358
410
  count: int
359
- totals: dict[str, Any] = {}
411
+ measures: dict[str, Any] = {}
360
412
  criteria: "Criteria"
361
413
  ids: list[Any] = []
362
414
  """The identities of this group's first page, in the criteria's order; empty unless the
@@ -371,7 +423,7 @@ class PivotCell(DataTransferObject):
371
423
  """One cell of a pivot: what its rows share on both axes, how many there are, and the
372
424
  numbers folded out of them.
373
425
 
374
- out PivotCell(row=['SAL'], column=['2026-01'], count=412, totals={'debit': …})
426
+ out PivotCell(row=['SAL'], column=['2026-01'], count=412, measures={'debit': …})
375
427
 
376
428
  A margin is a cell with one axis empty: `column=[]` is the whole row, `row=[]` the whole
377
429
  column, and both empty is the grand total.
@@ -380,14 +432,14 @@ class PivotCell(DataTransferObject):
380
432
  row: list[Any] = []
381
433
  column: list[Any] = []
382
434
  count: int = 0
383
- totals: dict[str, Any] = {}
435
+ measures: dict[str, Any] = {}
384
436
 
385
437
 
386
438
  class Pivot(DataTransferObject):
387
439
  """A table of groups crossed by groups: rows down one axis, columns across the other, a
388
440
  folded cell where they meet.
389
441
 
390
- in rows=[journal_id], columns=[posted_at:month], totals={'debit': sum debit}
442
+ in rows=[journal_id], columns=[posted_at:month], measures={'debit': sum debit}
391
443
  out Pivot(rows=[['SAL'], ['PUR']], columns=[['2026-01'], ['2026-02']], cells=[…])
392
444
 
393
445
  `rows` and `columns` are every key that appeared, in order, so a client renders the grid
@@ -544,21 +596,22 @@ class Criteria(DataTransferObject):
544
596
  @field_validator("grouping", mode="before")
545
597
  @classmethod
546
598
  def _reads_the_grouping(cls, value: Any) -> Any:
547
- """The levels and the folds of the object, each to its type.
599
+ """The levels and the measures of the object, each to its type.
548
600
 
549
- in {"by": [{"field": "produced_by"}], "totals": {"filas": ["sum", "row_count"]}}
550
- out Grouping(by=(Level(produced_by),), totals={'filas': Fold(sum, row_count)})
601
+ in {"by": [{"field": "produced_by"}], "measures": {"filas": ["sum", "row_count"]}}
602
+ out Grouping(by=(Level(produced_by),), measures={'filas': Measure(sum, row_count)})
551
603
  """
552
604
  if isinstance(value, dict):
553
605
  value = dict(value)
554
- if "by" in value:
555
- value["by"] = tuple(Level.read(one) for one in value["by"])
556
- if "totals" in value:
557
- value["totals"] = {
558
- name: Fold.read(fold) for name, fold in (value["totals"] or {}).items()
606
+ if "group_by" in value:
607
+ value["group_by"] = tuple(Level.read(one) for one in value["group_by"])
608
+ if "measures" in value:
609
+ value["measures"] = {
610
+ name: Measure.read(measure)
611
+ for name, measure in (value["measures"] or {}).items()
559
612
  }
560
- if "having" in value:
561
- value["having"] = expression_from(value["having"])
613
+ if "where_measures" in value:
614
+ value["where_measures"] = expression_from(value["where_measures"])
562
615
  if isinstance(value.get("order"), str):
563
616
  value["order"] = parse_order(value["order"])
564
617
  return value
@@ -26,13 +26,17 @@ Python 3.12 and 3.13 have no `uuid.uuid7`; `uuid7()` below is RFC 9562 by hand a
26
26
  the standard library where it exists.
27
27
  """
28
28
 
29
+ import dataclasses
29
30
  import os
30
31
  import threading
31
32
  import time
32
33
  import uuid
33
34
  from dataclasses import dataclass, field
34
35
  from datetime import UTC, datetime
35
- from typing import TYPE_CHECKING, NotRequired, TypedDict
36
+ from functools import lru_cache
37
+ from typing import TYPE_CHECKING, Any, NotRequired, TypedDict
38
+
39
+ from pydantic import TypeAdapter
36
40
 
37
41
  if TYPE_CHECKING:
38
42
  from sincpro_framework.ddd.events import DomainEvent
@@ -40,6 +44,11 @@ if TYPE_CHECKING:
40
44
  RECORDED = "_recorded_events"
41
45
 
42
46
 
47
+ @lru_cache(maxsize=None)
48
+ def json_serializer(cls: type) -> TypeAdapter:
49
+ return TypeAdapter(cls)
50
+
51
+
43
52
  _MINTING = threading.Lock()
44
53
  _last_millisecond = 0
45
54
  _counter = 0
@@ -90,11 +99,11 @@ def utc_now() -> datetime:
90
99
 
91
100
 
92
101
  @dataclass(kw_only=True)
93
- class Audited:
102
+ class AuditedMixin:
94
103
  """Who wrote the record, beside when: a mixin an aggregate opts into.
95
104
 
96
105
  @dataclass
97
- class Invoice(Audited, Entity):
106
+ class Invoice(AuditedMixin, Entity):
98
107
  number: str
99
108
 
100
109
  Nobody writes these two. The adapter stamps them on every flush from the actor the
@@ -110,11 +119,11 @@ class Audited:
110
119
 
111
120
 
112
121
  @dataclass(kw_only=True)
113
- class Archivable:
122
+ class ArchivableMixin:
114
123
  """Put away rather than deleted: a mixin an aggregate opts into.
115
124
 
116
125
  @dataclass
117
- class Account(Archivable, Entity):
126
+ class Account(ArchivableMixin, Entity):
118
127
  code: str
119
128
 
120
129
  repository.remove(account) → archived_at stamped, the row stays
@@ -222,12 +231,11 @@ class Entity:
222
231
  announce facts a rollback then undoes.
223
232
  """
224
233
  recorded: list[DomainEvent] = self.__dict__.setdefault(RECORDED, [])
225
- stamped = event.model_copy(
226
- update={
227
- "aggregate_type": event.aggregate_type or type(self).__name__,
228
- "aggregate_id": event.aggregate_id or self.id,
229
- "sequence": len(recorded),
230
- }
234
+ stamped = dataclasses.replace(
235
+ event,
236
+ entity_type=event.entity_type or type(self).__name__,
237
+ entity_id=event.entity_id or self.id,
238
+ sequence=len(recorded),
231
239
  )
232
240
  recorded.append(stamped)
233
241
  return stamped
@@ -242,3 +250,29 @@ class Entity:
242
250
  """
243
251
  recorded = self.__dict__.pop(RECORDED, [])
244
252
  return list(recorded)
253
+
254
+ def as_json(self) -> str:
255
+ """This entity as JSON text.
256
+
257
+ >>> Note(title="x").as_json()
258
+ '{"id":"...","created_at":"...","title":"x"}'
259
+ """
260
+ return json_serializer(type(self)).dump_json(self).decode()
261
+
262
+ @classmethod
263
+ def from_json(cls, data: "str | dict[str, Any]") -> "Any":
264
+ """Rebuilds an instance from JSON text or an already-parsed dict — either is
265
+ accepted, so a caller that already has the dict (a DB row, another service's
266
+ response) never has to round-trip it through a string first.
267
+
268
+ >>> Note.from_json('{"title": "x"}')
269
+ Note(title='x')
270
+ >>> Note.from_json({"title": "x"})
271
+ Note(title='x')
272
+ """
273
+ adapter = json_serializer(cls)
274
+ return (
275
+ adapter.validate_json(data)
276
+ if isinstance(data, str)
277
+ else adapter.validate_python(data)
278
+ )
@@ -275,13 +275,13 @@ class EntityCollection[T]:
275
275
  """The values an aggregation is about, once it is established that it may answer.
276
276
 
277
277
  complete EntityCollection([a(2), b(3)]) → [2, 3]
278
- partial a page of 20 out of 8412 → ContractViolation, naming `self.repository.totals(...)`
278
+ partial a page of 20 out of 8412 → ContractViolation, naming `self.repository.measures(...)`
279
279
  """
280
280
  if self.is_partial:
281
281
  raise ContractViolation(
282
282
  f"{name} would answer about {len(self.items)} records out of "
283
283
  f"{self.count or 'more'}; ask the engine instead, which folds the whole "
284
- f"result set: self.repository.totals(<aggregate>, criteria, total='sum:<field>')"
284
+ f"result set: self.repository.measures(<aggregate>, criteria, total=('sum', '<field>'))"
285
285
  )
286
286
  return [selector(record) for record in self.items]
287
287
 
@@ -543,7 +543,7 @@ class EntityCollection[T]:
543
543
 
544
544
  in EntityCollection([a(2), b(3)]) complete, lambda x: x.size
545
545
  out 5
546
- but a page of 20 out of 8412 raises: ask `self.repository.totals(...)` instead
546
+ but a page of 20 out of 8412 raises: ask `self.repository.measures(...)` instead
547
547
  """
548
548
  return sum(self._fold("sum_by", selector))
549
549