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.
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/PKG-INFO +4 -2
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/pyproject.toml +3 -1
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/bus.py +21 -17
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/bus.pyi +2 -2
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/__init__.py +7 -5
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/criteria.py +98 -45
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/entity.py +45 -11
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/entity_collection.py +3 -3
- sincpro_framework-3.7.2/sincpro_framework/ddd/events.py +142 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/memory_repository.py +72 -43
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/query.py +41 -1
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/relations.py +1 -1
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/events/queue.py +3 -2
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/events/subscriber.py +2 -9
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/introspection/inspector.py +5 -8
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ioc.py +41 -12
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/data_mapper.py +2 -2
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/database.py +1 -1
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/repository.py +114 -89
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/sql_translator.py +9 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/sincpro_abstractions.py +1 -1
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/sincpro_abstractions.pyi +1 -1
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/use_bus.py +33 -1
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/use_bus.pyi +14 -6
- sincpro_framework-3.7.0/sincpro_framework/ddd/events.py +0 -59
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/LICENSE.md +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/README.md +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/aio/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/aio/bus.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/aio/bus.pyi +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/conf/sincpro_framework_conf.yml +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/framework_context.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/framework_context.pyi +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/framework_context_consumer.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/mixin.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/context/thread_context_bus.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/evaluate.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/exceptions.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/model_meta.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/pagination.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/repository.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/value_object.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/deps.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/catalog.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/const.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/json_utils.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/mcp/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/mcp/entrypoint.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/mcp/mcp.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/rpc/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/rpc/entrypoint.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/rpc/jrpc.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/entrypoints/scalar_executor.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/error_handler.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/events/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/events/publisher.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/exceptions.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/introspection/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/middleware.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/api.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/domain.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/errors/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/errors/record_error.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/errors/setup.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/registry.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/setup.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/span_context.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/span_error.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/observability/tracing/span_execution.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/__init__.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/custom_fields.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/model_introspection.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/observability.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/orm/sqlalchemy/relation_resolver.py +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/py.typed +0 -0
- {sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/sincpro_conf.py +0 -0
- {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.
|
|
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.
|
|
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[
|
|
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
|
|
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
|
|
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
|
-
|
|
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[
|
|
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[
|
|
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
|
|
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
|
|
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
|
-
|
|
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[
|
|
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
|
|
160
|
+
f"Features and app services have the same DTO: {intersection_names}",
|
|
158
161
|
)
|
|
159
162
|
raise DTOAlreadyRegistered(
|
|
160
|
-
f"Data transfer object {
|
|
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
|
-
|
|
176
|
+
dto_type = dto.__class__
|
|
177
|
+
dto_name = dto_type.__name__
|
|
174
178
|
try:
|
|
175
179
|
if (
|
|
176
|
-
|
|
177
|
-
and
|
|
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
|
|
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
|
|
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[
|
|
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[
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
"
|
|
52
|
-
"
|
|
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
|
-
|
|
213
|
-
""
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
229
|
+
in {"function": "sum", "field": "row_count"}
|
|
230
|
+
out Measure(function='sum', field='row_count') → sum(row_count) AS <name>
|
|
219
231
|
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
255
|
+
if function not in MEASURE_FUNCTIONS:
|
|
234
256
|
raise InvalidCriteria(
|
|
235
|
-
f"'{function}' is not a
|
|
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) -> "
|
|
241
|
-
"""A
|
|
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
|
-
>>>
|
|
244
|
-
|
|
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,
|
|
289
|
+
if isinstance(written, Measure):
|
|
247
290
|
return written
|
|
248
291
|
if isinstance(written, dict):
|
|
249
292
|
return cls(**written)
|
|
250
|
-
|
|
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
|
-
|
|
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"], "
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 `
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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],
|
|
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
|
|
599
|
+
"""The levels and the measures of the object, each to its type.
|
|
548
600
|
|
|
549
|
-
in {"by": [{"field": "produced_by"}], "
|
|
550
|
-
out Grouping(by=(Level(produced_by),),
|
|
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 "
|
|
555
|
-
value["
|
|
556
|
-
if "
|
|
557
|
-
value["
|
|
558
|
-
name:
|
|
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 "
|
|
561
|
-
value["
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
122
|
+
class ArchivableMixin:
|
|
114
123
|
"""Put away rather than deleted: a mixin an aggregate opts into.
|
|
115
124
|
|
|
116
125
|
@dataclass
|
|
117
|
-
class Account(
|
|
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 =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
)
|
{sincpro_framework-3.7.0 → sincpro_framework-3.7.2}/sincpro_framework/ddd/entity_collection.py
RENAMED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|