python-ddd-framework 0.4.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

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 (104) hide show
  1. python_ddd_framework/application/runtime.py +46 -19
  2. python_ddd_framework/application_services/bindings.py +0 -64
  3. python_ddd_framework/application_services/dispatcher.py +1 -1
  4. python_ddd_framework/application_services/invocation.py +7 -500
  5. python_ddd_framework/application_services/module.py +16 -5
  6. python_ddd_framework/authorization/module.py +1 -1
  7. python_ddd_framework/background_execution/local.py +20 -1
  8. python_ddd_framework/background_execution/processes.py +11 -1
  9. python_ddd_framework/background_jobs/execution.py +1 -1
  10. python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
  11. python_ddd_framework/background_workers/execution.py +1 -1
  12. python_ddd_framework/background_workers/runtime.py +11 -2
  13. python_ddd_framework/caching/unit_of_work.py +2 -4
  14. python_ddd_framework/cli/project.py +11 -10
  15. python_ddd_framework/developer_kit/generation.py +46 -36
  16. python_ddd_framework/developer_kit/publication.py +442 -0
  17. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
  18. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
  19. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
  20. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
  21. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
  22. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
  23. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
  24. python_ddd_framework/diagnostics/source.py +14 -1
  25. python_ddd_framework/domain/aggregates.py +5 -12
  26. python_ddd_framework/domain/event_values.py +124 -0
  27. python_ddd_framework/events/runtime.py +2 -7
  28. python_ddd_framework/events/unit_of_work.py +1 -4
  29. python_ddd_framework/fastapi/action.py +2 -1
  30. python_ddd_framework/fastapi/adapter.py +11 -117
  31. python_ddd_framework/fastapi/application_services.py +17 -402
  32. python_ddd_framework/fastapi/error_handlers.py +128 -0
  33. python_ddd_framework/fastapi/realtime/runtime.py +1 -1
  34. python_ddd_framework/fastapi/request_context.py +2 -5
  35. python_ddd_framework/fastapi/routing.py +4 -10
  36. python_ddd_framework/fastapi/service_endpoints.py +182 -0
  37. python_ddd_framework/fastapi/service_routes.py +237 -0
  38. python_ddd_framework/fastapi/transfer.py +1 -1
  39. python_ddd_framework/hosted_services/bridge.py +76 -21
  40. python_ddd_framework/hosted_services/contracts.py +5 -1
  41. python_ddd_framework/hosted_services/runtime.py +71 -17
  42. python_ddd_framework/identity/__init__.py +21 -13
  43. python_ddd_framework/identity/application.py +12 -66
  44. python_ddd_framework/identity/contracts.py +38 -411
  45. python_ddd_framework/identity/domain_rules.py +17 -0
  46. python_ddd_framework/identity/dtos.py +152 -0
  47. python_ddd_framework/identity/extensions.py +9 -0
  48. python_ddd_framework/identity/management.py +28 -25
  49. python_ddd_framework/identity/models.py +86 -0
  50. python_ddd_framework/identity/module.py +3 -4
  51. python_ddd_framework/identity/options.py +54 -0
  52. python_ddd_framework/identity/seeding.py +59 -0
  53. python_ddd_framework/identity/services.py +1 -1
  54. python_ddd_framework/identity/sqlalchemy/module.py +9 -6
  55. python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
  56. python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
  57. python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
  58. python_ddd_framework/identity/stores.py +120 -0
  59. python_ddd_framework/identity/tokens.py +1 -1
  60. python_ddd_framework/invocation/callables.py +1 -1
  61. python_ddd_framework/invocation/dispatcher.py +1 -1
  62. python_ddd_framework/invocation/entrypoints.py +2 -1
  63. python_ddd_framework/invocation/function_runtime.py +2 -2
  64. python_ddd_framework/invocation/interception.py +1 -1
  65. python_ddd_framework/invocation/managed_proxy.py +2 -1
  66. python_ddd_framework/invocation/managed_services.py +8 -14
  67. python_ddd_framework/invocation/methods.py +2 -7
  68. python_ddd_framework/invocation/module.py +1 -1
  69. python_ddd_framework/invocation/runtime.py +483 -0
  70. python_ddd_framework/invocation/scopes.py +91 -0
  71. python_ddd_framework/lifecycle/participants.py +4 -0
  72. python_ddd_framework/messaging/channel.py +35 -8
  73. python_ddd_framework/messaging/contracts.py +9 -0
  74. python_ddd_framework/messaging/module.py +13 -0
  75. python_ddd_framework/messaging/runtime.py +17 -2
  76. python_ddd_framework/modularity/discovery.py +2 -7
  77. python_ddd_framework/modularity/graph.py +2 -8
  78. python_ddd_framework/observability/logging.py +1 -1
  79. python_ddd_framework/observability/tracing.py +1 -1
  80. python_ddd_framework/redis/cache.py +2 -0
  81. python_ddd_framework/redis/notification_runtime.py +1 -1
  82. python_ddd_framework/services/arbitration.py +1 -20
  83. python_ddd_framework/services/composition.py +71 -2
  84. python_ddd_framework/services/provider.py +23 -1
  85. python_ddd_framework/services/relocation.py +96 -0
  86. python_ddd_framework/settings/refresh.py +1 -1
  87. python_ddd_framework/settings/sqlalchemy/store.py +1 -1
  88. python_ddd_framework/sqlalchemy/metadata.py +11 -413
  89. python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
  90. python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
  91. python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
  92. python_ddd_framework/sqlalchemy/migration.py +2 -1
  93. python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
  94. python_ddd_framework/sqlalchemy/module.py +2 -1
  95. python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
  96. python_ddd_framework/unit_of_work/contracts.py +68 -7
  97. python_ddd_framework/unit_of_work/manager.py +4 -7
  98. python_ddd_framework/unit_of_work/module.py +1 -1
  99. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
  100. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
  101. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
  102. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
  103. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
  104. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
@@ -0,0 +1,128 @@
1
+ """HTTP 错误状态映射与安全字段输出;安装和退出仍归 Adapter。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import cast, get_args
7
+
8
+ from fastapi.exceptions import RequestValidationError
9
+ from pydantic_core.core_schema import ErrorType
10
+ from starlette.requests import Request
11
+ from starlette.responses import JSONResponse
12
+
13
+ from ..application_services import ApplicationServiceValidationError
14
+ from ..errors import BusinessError, FrameworkError
15
+
16
+ # 保留已有日志类别,避免纯职责搬迁改变 Host 的日志筛选行为。
17
+ _LOGGER = logging.getLogger(f"{__package__}.adapter")
18
+ _SAFE_REQUEST_LOCATION_ROOTS = frozenset({"body", "cookie", "header", "path", "query"})
19
+ _TRUSTED_VALIDATION_ERROR_TYPES = frozenset(get_args(ErrorType))
20
+
21
+
22
+ async def _business_error_handler(request: Request, error: Exception) -> JSONResponse:
23
+ business_error = cast(BusinessError, error)
24
+ return JSONResponse(
25
+ status_code=400,
26
+ content={
27
+ "error": {
28
+ "code": business_error.definition.code,
29
+ "message": business_error.definition.message,
30
+ "fields": [field.name for field in business_error.fields],
31
+ **(
32
+ {
33
+ "data": business_error.data.model_dump(
34
+ mode="json", by_alias=True, warnings="error"
35
+ )
36
+ }
37
+ if business_error.data is not None
38
+ else {}
39
+ ),
40
+ }
41
+ },
42
+ )
43
+
44
+
45
+ async def _framework_error_handler(request: Request, error: Exception) -> JSONResponse:
46
+ framework_error = cast(FrameworkError, error)
47
+ _LOGGER.error(
48
+ "Unhandled FrameworkError for %s %s",
49
+ request.method,
50
+ request.url.path,
51
+ exc_info=(type(framework_error), framework_error, framework_error.__traceback__),
52
+ )
53
+ return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
54
+
55
+
56
+ async def _application_service_validation_handler(
57
+ request: Request,
58
+ error: Exception,
59
+ ) -> JSONResponse:
60
+ validation_error = cast(ApplicationServiceValidationError, error)
61
+ return JSONResponse(
62
+ status_code=422,
63
+ content={
64
+ "detail": [
65
+ {
66
+ "loc": list(issue.location),
67
+ "msg": issue.message,
68
+ "type": issue.error_type,
69
+ }
70
+ for issue in validation_error.issues
71
+ ]
72
+ },
73
+ )
74
+
75
+
76
+ async def _request_validation_handler(request: Request, error: Exception) -> JSONResponse:
77
+ validation_error = cast(RequestValidationError, error)
78
+ return JSONResponse(
79
+ status_code=422,
80
+ content={
81
+ "detail": [
82
+ {
83
+ "loc": list(_safe_request_validation_location(detail.get("loc"))),
84
+ "msg": "Input validation failed",
85
+ "type": _trusted_request_validation_error_type(detail.get("type")),
86
+ }
87
+ for detail in validation_error.errors()
88
+ ]
89
+ },
90
+ )
91
+
92
+
93
+ def _safe_request_validation_location(location: object) -> tuple[str, ...]:
94
+ if (
95
+ isinstance(location, tuple)
96
+ and location
97
+ and isinstance(location[0], str)
98
+ and location[0] in _SAFE_REQUEST_LOCATION_ROOTS
99
+ ):
100
+ # 后续 segment 可能来自用户控制的 mapping key;只发布 FastAPI 自有位置类别。
101
+ return (location[0],)
102
+ return ()
103
+
104
+
105
+ def _trusted_request_validation_error_type(error_type: object) -> str:
106
+ if isinstance(error_type, str) and error_type in _TRUSTED_VALIDATION_ERROR_TYPES:
107
+ return error_type
108
+ return "value_error"
109
+
110
+
111
+ async def _unauthenticated_handler(request: Request, error: Exception) -> JSONResponse:
112
+ return JSONResponse(
113
+ status_code=401,
114
+ content={"detail": "Not authenticated"},
115
+ headers={"WWW-Authenticate": "Bearer"},
116
+ )
117
+
118
+
119
+ async def _permission_denied_handler(request: Request, error: Exception) -> JSONResponse:
120
+ return JSONResponse(status_code=403, content={"detail": "Forbidden"})
121
+
122
+
123
+ async def _resource_conflict_handler(request: Request, error: Exception) -> JSONResponse:
124
+ return JSONResponse(status_code=409, content={"detail": "Resource conflict"})
125
+
126
+
127
+ async def _resource_not_found_handler(request: Request, error: Exception) -> JSONResponse:
128
+ return JSONResponse(status_code=404, content={"detail": "Resource not found"})
@@ -11,8 +11,8 @@ from uuid import UUID, uuid4
11
11
  from dishka import AsyncContainer
12
12
  from fastapi import WebSocket
13
13
 
14
- from ...application_services.invocation import _InvocationRuntime
15
14
  from ...authorization import AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
15
+ from ...invocation.runtime import _InvocationRuntime
16
16
  from ...options import Options
17
17
  from ...realtime import (
18
18
  RealtimeDeliveryReport,
@@ -18,15 +18,12 @@ from starlette.responses import JSONResponse
18
18
  from starlette.types import ASGIApp, Message, Receive, Scope, Send
19
19
 
20
20
  from ..application import Application
21
- from ..application_services.invocation import (
22
- _close_action,
23
- _InvocationRuntime,
24
- _raise_scope_failure,
25
- )
26
21
  from ..auditing import AuditSink, RequestAuditRecord
27
22
  from ..authorization import AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
28
23
  from ..invocation.contracts import _is_safe_correlation_id
29
24
  from ..invocation.entrypoints import http_request_lease
25
+ from ..invocation.runtime import _InvocationRuntime
26
+ from ..invocation.scopes import _close_action, _raise_scope_failure
30
27
  from ..unit_of_work import UnitOfWorkManager
31
28
  from .errors import FastApiAdapterError
32
29
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import inspect
6
5
  import re
7
6
  from collections.abc import Mapping, Sequence
8
7
  from dataclasses import dataclass
@@ -17,11 +16,9 @@ from starlette.routing import BaseRoute, WebSocketRoute
17
16
  from ..application import Application
18
17
  from ..authorization.module import AuthorizationModule
19
18
  from ..diagnostics import SourceLocation
19
+ from ..diagnostics.source import _source_file_line
20
20
  from ..modularity import ModuleKey
21
- from .application_services import (
22
- _ROUTE_SOURCE_ATTRIBUTE,
23
- _application_service_routers,
24
- )
21
+ from .application_services import _application_service_routers
25
22
  from .contracts import (
26
23
  RouterCatalog,
27
24
  RouterRegistration,
@@ -33,6 +30,7 @@ from .health import _create_health_router
33
30
  from .http_router import HttpRouter
34
31
  from .manual_action import _ActionRoute
35
32
  from .realtime import websocket_connection
33
+ from .service_endpoints import _ROUTE_SOURCE_ATTRIBUTE
36
34
 
37
35
  _PATH_PARAMETER = re.compile(r"\{[^{}:]+(?P<converter>:[^{}]+)?\}")
38
36
 
@@ -335,11 +333,7 @@ def _source_for_context(context: RouteContext, owner_label: str) -> SourceLocati
335
333
  if isinstance(declared_source, SourceLocation):
336
334
  return declared_source
337
335
  symbol = getattr(endpoint, "__qualname__", getattr(endpoint, "__name__", None))
338
- try:
339
- file_name = inspect.getsourcefile(endpoint)
340
- _, line = inspect.getsourcelines(endpoint)
341
- except (OSError, TypeError):
342
- pass
336
+ file_name, line = _source_file_line(endpoint)
343
337
  return SourceLocation(owner=owner_label, file=file_name, line=line, symbol=symbol)
344
338
 
345
339
 
@@ -0,0 +1,182 @@
1
+ """约定服务 endpoint 的请求执行、原生绑定与 ACTION 生命周期接入。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Awaitable, Callable, Coroutine
7
+ from dataclasses import dataclass
8
+ from typing import Any, cast
9
+
10
+ from dishka import AsyncContainer
11
+ from dishka.integrations.fastapi import DishkaRoute, FromDishka
12
+ from starlette.requests import Request
13
+ from starlette.responses import Response
14
+
15
+ from ..application_services.catalog import _CompiledApplicationService
16
+ from ..application_services.dispatcher import _ApplicationServiceDispatcher
17
+ from ..diagnostics import SourceLocation
18
+ from ..invocation.methods import _CompiledMethod
19
+ from .action import _HttpActionOwner, _request_parameter
20
+ from .errors import FastApiAdapterError
21
+ from .filters import _filters_for, _ordered_filters
22
+ from .parameters import _HttpParameterValidator
23
+ from .service_routes import _RouteMethod
24
+ from .transfer import _manage_stream
25
+ from .uploads import _prepare_upload
26
+
27
+ _ROUTE_SOURCE_ATTRIBUTE = "__python_ddd_framework_fastapi_route_source__"
28
+ _INVOCATION_IDENTITY_ATTRIBUTE = "__python_ddd_framework_fastapi_invocation_identity__"
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class _ApplicationServiceRouteIdentity:
33
+ service: _CompiledApplicationService
34
+ method: _CompiledMethod
35
+
36
+
37
+ class _ApplicationServiceRoute(DishkaRoute):
38
+ """在 native FastAPI binding 前建立 catalog-identity invocation boundary。"""
39
+
40
+ __slots__ = ("_compiled_method", "_compiled_service")
41
+
42
+ def __init__(
43
+ self,
44
+ path: str,
45
+ endpoint: Callable[..., Any],
46
+ **kwargs: Any,
47
+ ) -> None:
48
+ identity = getattr(endpoint, _INVOCATION_IDENTITY_ATTRIBUTE, None)
49
+ if not isinstance(identity, _ApplicationServiceRouteIdentity):
50
+ raise FastApiAdapterError("generated Application Service route lost its identity")
51
+ self._compiled_service = identity.service
52
+ self._compiled_method = identity.method
53
+ sources = getattr(endpoint, "__python_ddd_framework_http_sources__", None)
54
+ super().__init__(path, endpoint, **kwargs)
55
+ cast(Any, self.endpoint).__python_ddd_framework_http_sources__ = sources
56
+ # include_router 会以已注入 endpoint 重建 route;显式把 identity 传递给下一次构造。
57
+ setattr(self.endpoint, _INVOCATION_IDENTITY_ATTRIBUTE, identity)
58
+
59
+ def get_route_handler(
60
+ self,
61
+ ) -> Callable[[Request], Coroutine[Any, Any, Response]]:
62
+ native_handler = super().get_route_handler()
63
+ service = self._compiled_service
64
+ method = self._compiled_method
65
+
66
+ async def route_handler(request: Request) -> Response:
67
+ owner = await _HttpActionOwner.install(request)
68
+ container = cast(AsyncContainer, request.state.dishka_container)
69
+ dispatcher = await container.get(_ApplicationServiceDispatcher)
70
+
71
+ async def bind() -> Response:
72
+ await _prepare_upload(request, method.implementation_method, self)
73
+ return await native_handler(request)
74
+
75
+ try:
76
+ result = await dispatcher.invoke_http_request(
77
+ service,
78
+ method,
79
+ bind,
80
+ is_short_circuited=lambda: owner.short_circuited,
81
+ defer_audit=owner.audits.append,
82
+ )
83
+ owner.response_ready = isinstance(result, Response)
84
+ except BaseException as error:
85
+ owner.failure = error
86
+ raise
87
+ if not isinstance(result, Response):
88
+ raise FastApiAdapterError(
89
+ "an interceptor outside HTTP binding must return a native Response"
90
+ )
91
+ _manage_stream(request, result)
92
+ return result
93
+
94
+ return route_handler
95
+
96
+
97
+ def _create_endpoint(
98
+ service: _CompiledApplicationService,
99
+ method: _CompiledMethod,
100
+ route_method: _RouteMethod,
101
+ source: SourceLocation,
102
+ ) -> Callable[..., Awaitable[object]]:
103
+ """保留 Protocol 原签名供 FastAPI binding,并注入同一 REQUEST dispatcher。"""
104
+
105
+ contract = service.registration.contract
106
+ method_name = method.name
107
+ parameter_names = {parameter.name for parameter in route_method.parameters}
108
+ dispatcher_parameter_name = "_python_ddd_framework_application_service_dispatcher"
109
+ while dispatcher_parameter_name in parameter_names:
110
+ dispatcher_parameter_name = f"_{dispatcher_parameter_name}"
111
+
112
+ declared_filters = _ordered_filters(
113
+ _filters_for(*reversed(service.registration.implementation.__mro__)),
114
+ _filters_for(method.implementation_method),
115
+ )
116
+ validator = _HttpParameterValidator(
117
+ route_method.signature.replace(parameters=route_method.parameters)
118
+ )
119
+
120
+ async def endpoint(**values: object) -> object:
121
+ dispatcher = cast(_ApplicationServiceDispatcher, values.pop(dispatcher_parameter_name))
122
+ request = cast(
123
+ Request,
124
+ values.pop(request_parameter_name)
125
+ if injected_request
126
+ else values[request_parameter_name],
127
+ )
128
+ owner: _HttpActionOwner = request.state.python_ddd_framework_action
129
+ await owner.activate(
130
+ method.descriptor.unit_of_work, method.name, service.registration.connection_name
131
+ )
132
+ filters = _ordered_filters(
133
+ getattr(request.app.state, "python_ddd_framework_filters", ()), declared_filters
134
+ )
135
+ snapshots = validator.capture(values)
136
+
137
+ async def invoke(actual: dict[str, object]) -> object:
138
+ if filters:
139
+ validator.validate_changes(actual, snapshots)
140
+ return await dispatcher.invoke_http(service, method, actual)
141
+
142
+ return await owner.invoke(
143
+ method.implementation_method,
144
+ values,
145
+ filters,
146
+ invoke,
147
+ )
148
+
149
+ request_parameter, injected_request = _request_parameter(route_method.parameters)
150
+ request_parameter_name = request_parameter.name
151
+ http_parameters = (
152
+ (*route_method.parameters, request_parameter)
153
+ if injected_request
154
+ else route_method.parameters
155
+ )
156
+
157
+ dispatcher_parameter = inspect.Parameter(
158
+ dispatcher_parameter_name,
159
+ kind=inspect.Parameter.KEYWORD_ONLY,
160
+ annotation=cast(Any, FromDishka)[_ApplicationServiceDispatcher],
161
+ )
162
+ # 动态 endpoint 的 signature/annotations 同时发布,兼容 FastAPI 与 postponed annotations。
163
+ signature = route_method.signature.replace(parameters=(*http_parameters, dispatcher_parameter))
164
+ endpoint_name = f"{contract.__name__}_{method_name}"
165
+ endpoint.__name__ = endpoint_name
166
+ endpoint.__qualname__ = endpoint_name
167
+ endpoint.__module__ = contract.__module__
168
+ cast(Any, endpoint).__signature__ = signature
169
+ endpoint.__annotations__ = {
170
+ **{
171
+ parameter.name: parameter.annotation
172
+ for parameter in (*http_parameters, dispatcher_parameter)
173
+ },
174
+ "return": signature.return_annotation,
175
+ }
176
+ setattr(endpoint, _ROUTE_SOURCE_ATTRIBUTE, source)
177
+ setattr(
178
+ endpoint,
179
+ _INVOCATION_IDENTITY_ATTRIBUTE,
180
+ _ApplicationServiceRouteIdentity(service, method),
181
+ )
182
+ return endpoint
@@ -0,0 +1,237 @@
1
+ """约定服务的名称、路径和 HTTP 签名编译;不处理请求生命周期。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import re
7
+ import types
8
+ from dataclasses import dataclass, is_dataclass
9
+ from datetime import date, datetime, time
10
+ from decimal import Decimal
11
+ from enum import Enum
12
+ from typing import Annotated, Any, Literal, Union, get_args, get_origin
13
+ from uuid import UUID
14
+
15
+ from dishka.integrations.base import default_parse_dependency
16
+ from fastapi.params import Body, Param, Path, Query
17
+ from fastapi.params import Depends as DependsParameter
18
+ from pydantic import BaseModel
19
+ from pydantic.fields import FieldInfo
20
+
21
+ from .contracts import ApplicationServiceRouteOverride
22
+ from .errors import FastApiAdapterError
23
+ from .http import _metadata
24
+
25
+ _APPLICATION_SERVICE_SUFFIX = "ApplicationService"
26
+ _PATH_PARAMETER = re.compile(r"\{(?P<name>[^{}:]+)(?::[^{}]+)?\}")
27
+ _WORD_BOUNDARY_1 = re.compile(r"(.)([A-Z][a-z]+)")
28
+ _WORD_BOUNDARY_2 = re.compile(r"([a-z0-9])([A-Z])")
29
+ _VERB_PREFIXES: tuple[tuple[str, str], ...] = (
30
+ ("get_list", "GET"),
31
+ ("get_all", "GET"),
32
+ ("get", "GET"),
33
+ ("create", "POST"),
34
+ ("add", "POST"),
35
+ ("insert", "POST"),
36
+ ("post", "POST"),
37
+ ("update", "PUT"),
38
+ ("put", "PUT"),
39
+ ("delete", "DELETE"),
40
+ ("remove", "DELETE"),
41
+ ("patch", "PATCH"),
42
+ )
43
+ _SCALAR_TYPES = (str, int, float, bool, UUID, Decimal, date, datetime, time)
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class _RouteMethod:
48
+ signature: inspect.Signature
49
+ parameters: tuple[inspect.Parameter, ...]
50
+
51
+
52
+ def _inspect_route_method(
53
+ contract: type[object],
54
+ method_name: str,
55
+ signature: inspect.Signature,
56
+ ) -> _RouteMethod:
57
+ """在真实 FastAPI app 变更前解析并收窄动态 typing boundary。"""
58
+
59
+ # 共享方法编译已解释本 Application 的扩展 DTO;HTTP 不另建字段权威。
60
+ resolved_parameters = tuple(signature.parameters.values())
61
+ parameters = resolved_parameters[1:]
62
+ for parameter in parameters:
63
+ if parameter.kind in (
64
+ inspect.Parameter.POSITIONAL_ONLY,
65
+ inspect.Parameter.VAR_POSITIONAL,
66
+ inspect.Parameter.VAR_KEYWORD,
67
+ ):
68
+ raise FastApiAdapterError(
69
+ f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
70
+ "cannot use positional-only, *args, or **kwargs parameters"
71
+ )
72
+ if parameter.annotation is inspect.Signature.empty:
73
+ raise FastApiAdapterError(
74
+ f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
75
+ f"parameter {parameter.name!r} requires a type annotation"
76
+ )
77
+ if signature.return_annotation is inspect.Signature.empty:
78
+ raise FastApiAdapterError(
79
+ f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
80
+ "requires a return type annotation"
81
+ )
82
+ return _RouteMethod(signature=signature, parameters=parameters)
83
+
84
+
85
+ def _query_model_parameter(parameter: inspect.Parameter, verb: str) -> inspect.Parameter:
86
+ hint = parameter.annotation
87
+ metadata = get_args(hint)[1:] if get_origin(hint) is Annotated else ()
88
+ model = get_args(hint)[0] if metadata else hint
89
+ if (
90
+ verb == "GET"
91
+ and isinstance(model, type)
92
+ and issubclass(model, BaseModel)
93
+ and not any(
94
+ isinstance(value, (FieldInfo, DependsParameter))
95
+ for value in (*metadata, parameter.default)
96
+ )
97
+ and default_parse_dependency(parameter, hint) is None
98
+ ):
99
+ # 仅补原生 Query 元数据;字段展开、校验和 OpenAPI 均交给 FastAPI/Pydantic。
100
+ return parameter.replace(annotation=Annotated[hint, Query()])
101
+ return parameter
102
+
103
+
104
+ def _uses_non_path_binding(parameter: inspect.Parameter) -> bool:
105
+ metadata = (
106
+ get_args(parameter.annotation)[1:] if get_origin(parameter.annotation) is Annotated else ()
107
+ )
108
+ return any(
109
+ isinstance(item, (Param, Body)) and not isinstance(item, Path)
110
+ for item in (*metadata, parameter.default)
111
+ )
112
+
113
+
114
+ def _route_path(
115
+ service_path: str,
116
+ parameters: tuple[inspect.Parameter, ...],
117
+ override_path: str | None,
118
+ *,
119
+ contract: type[object],
120
+ method_name: str,
121
+ ) -> str:
122
+ # 只有唯一 required scalar id 才能无歧义进入 convention path;
123
+ # 其余情况保持 query 或要求 override。
124
+ id_candidates = tuple(
125
+ parameter
126
+ for parameter in parameters
127
+ if _is_required_path_parameter(parameter)
128
+ and (parameter.name == "id" or parameter.name.endswith("_id"))
129
+ and _is_scalar_annotation(parameter.annotation)
130
+ and not _uses_non_path_binding(parameter)
131
+ )
132
+ if override_path is None:
133
+ if len(id_candidates) > 1:
134
+ raise FastApiAdapterError(
135
+ f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
136
+ "has ambiguous required id parameters and requires an explicit path override"
137
+ )
138
+ path = service_path
139
+ if len(id_candidates) == 1:
140
+ path = f"{path}/{{{id_candidates[0].name}}}"
141
+ # 只有集中定义的 CRUD 名称省略动作;审批/取消等领域动作保持其 contract 名称。
142
+ if method_name not in {prefix for prefix, _ in _VERB_PREFIXES}:
143
+ path = f"{path}/{method_name.replace('_', '-')}"
144
+ return path
145
+
146
+ if "?" in override_path or "#" in override_path or "//" in override_path:
147
+ raise FastApiAdapterError("route override path cannot contain query or fragment syntax")
148
+ rendered_parameters = tuple(
149
+ match.group("name") for match in _PATH_PARAMETER.finditer(override_path)
150
+ )
151
+ if override_path.count("{") != len(rendered_parameters) or override_path.count("}") != len(
152
+ rendered_parameters
153
+ ):
154
+ raise FastApiAdapterError(f"route override path {override_path!r} is malformed")
155
+ parameters_by_name = {parameter.name: parameter for parameter in parameters}
156
+ for name in rendered_parameters:
157
+ parameter = parameters_by_name.get(name)
158
+ if (
159
+ parameter is None
160
+ or not _is_required_path_parameter(parameter)
161
+ or not _is_scalar_annotation(parameter.annotation)
162
+ or _uses_non_path_binding(parameter)
163
+ ):
164
+ raise FastApiAdapterError(
165
+ f"route override path parameter {name!r} must name a required scalar "
166
+ f"Protocol parameter on {contract.__qualname__}.{method_name}"
167
+ )
168
+ if len(set(rendered_parameters)) != len(rendered_parameters):
169
+ raise FastApiAdapterError(f"route override path {override_path!r} repeats a path parameter")
170
+ return override_path
171
+
172
+
173
+ def _is_required_path_parameter(parameter: inspect.Parameter) -> bool:
174
+ return parameter.default is inspect.Signature.empty or isinstance(parameter.default, Path)
175
+
176
+
177
+ def _is_scalar_annotation(annotation: object) -> bool:
178
+ current = annotation
179
+ if get_origin(current) is Annotated:
180
+ current = get_args(current)[0]
181
+ origin = get_origin(current)
182
+ if origin in (types.UnionType, Union):
183
+ members = tuple(item for item in get_args(current) if item is not type(None))
184
+ return len(members) == 1 and _is_scalar_annotation(members[0])
185
+ if origin is Literal:
186
+ return all(isinstance(item, (str, int, float, bool)) for item in get_args(current))
187
+ if not isinstance(current, type):
188
+ return False
189
+ if issubclass(current, BaseModel) or is_dataclass(current):
190
+ return False
191
+ return issubclass(current, (*_SCALAR_TYPES, Enum))
192
+
193
+
194
+ def _service_path(root_path: str, contract: type[object], segment: str | None = None) -> str:
195
+ service_name = contract.__name__
196
+ if service_name.endswith(_APPLICATION_SERVICE_SUFFIX):
197
+ service_name = service_name[: -len(_APPLICATION_SERVICE_SUFFIX)]
198
+ if not service_name:
199
+ raise FastApiAdapterError("application service contract has no routable name")
200
+ return "/" + "/".join(
201
+ part for part in (root_path, segment or _kebab_case(service_name)) if part
202
+ )
203
+
204
+
205
+ def _operation_id(contract: type[object], method_name: str) -> str:
206
+ service_path = _service_path("", contract).rsplit("/", maxsplit=1)[-1]
207
+ return f"{service_path.replace('-', '_')}_{method_name}"
208
+
209
+
210
+ def _kebab_case(value: str) -> str:
211
+ first_pass = _WORD_BOUNDARY_1.sub(r"\1-\2", value)
212
+ return _WORD_BOUNDARY_2.sub(r"\1-\2", first_pass).lower().replace("_", "-")
213
+
214
+
215
+ def _conventional_verb(method_name: str) -> str:
216
+ for prefix, verb in _VERB_PREFIXES:
217
+ if method_name == prefix or method_name.startswith(f"{prefix}_"):
218
+ return verb
219
+ return "POST"
220
+
221
+
222
+ def _route_projection(
223
+ method: object, override: ApplicationServiceRouteOverride | None
224
+ ) -> tuple[dict[str, Any], dict[str, str]]:
225
+ projection = dict(_metadata(method))
226
+ provenance = {key: "method" for key in projection}
227
+ if override is not None:
228
+ from dataclasses import fields
229
+
230
+ for field in fields(override):
231
+ if field.name in ("service", "method", "enabled"):
232
+ continue
233
+ value = getattr(override, field.name)
234
+ if value is not None:
235
+ projection[field.name] = value
236
+ provenance[field.name] = "module_override"
237
+ return projection, provenance
@@ -14,7 +14,7 @@ from starlette.requests import ClientDisconnect, Request
14
14
  from starlette.responses import StreamingResponse
15
15
  from starlette.types import Send
16
16
 
17
- from ..application_services.invocation import _InvocationRuntime
17
+ from ..invocation.runtime import _InvocationRuntime
18
18
 
19
19
 
20
20
  async def _close_form(form: FormData) -> None: