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
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  from collections import deque
6
6
  from contextvars import ContextVar
7
7
  from dataclasses import dataclass, field
8
- from typing import Protocol
9
8
  from uuid import UUID, uuid4
10
9
 
11
10
  from dishka import AsyncContainer, Scope
@@ -29,10 +28,6 @@ class _EventEnvelope:
29
28
  handled: set[object] = field(default_factory=set)
30
29
 
31
30
 
32
- class _EventHandler(Protocol):
33
- async def handle(self, event: object) -> None: ...
34
-
35
-
36
31
  class _LocalEventQueue:
37
32
  """队列只归一个具体 UoW;handler resolution 仍使用 Dishka 原生 scope。"""
38
33
 
@@ -76,7 +71,7 @@ class _LocalEventQueue:
76
71
  )
77
72
  assert request is not None
78
73
  # 每个 UoW 的 handler scope 独立;手动 UoW 的原调用 ACTION 可能已返回。
79
- from ..application_services.invocation import _managed_action
74
+ from ..invocation.scopes import _managed_action
80
75
 
81
76
  async with _managed_action(request, self._context, committed=lambda: False) as action:
82
77
  await self._drain_domain(action)
@@ -176,8 +171,8 @@ class _LocalEventQueue:
176
171
  previous = self._current
177
172
  self._current = envelope
178
173
  try:
179
- from ..application_services.invocation import _InvocationRuntime
180
174
  from ..invocation.interception import _EntryInterceptionCatalog
175
+ from ..invocation.runtime import _InvocationRuntime
181
176
 
182
177
  entries = await container.get(_EntryInterceptionCatalog)
183
178
  runtime = await container.get(_InvocationRuntime)
@@ -19,7 +19,4 @@ class _LocalEventLifecycleFactory:
19
19
 
20
20
 
21
21
  def _local_event_queue(work: UnitOfWork) -> _LocalEventQueue:
22
- for lifecycle in work._lifecycles:
23
- if isinstance(lifecycle, _LocalEventQueue):
24
- return lifecycle
25
- raise RuntimeError("Local event lifecycle is not configured for this UnitOfWork")
22
+ return work._lifecycle(_LocalEventQueue)
@@ -14,8 +14,9 @@ from anyio import CancelScope
14
14
  from dishka import AsyncContainer
15
15
  from starlette.requests import Request
16
16
 
17
- from ..application_services.invocation import _InvocationRuntime, _InvocationState, _managed_action
18
17
  from ..invocation import ApplicationInvocationContext
18
+ from ..invocation.runtime import _InvocationRuntime, _InvocationState
19
+ from ..invocation.scopes import _managed_action
19
20
  from ..unit_of_work import UnitOfWork, UnitOfWorkManager
20
21
  from ..unit_of_work.options import _DEFAULT_CONNECTION_NAME, UnitOfWorkDeclaration
21
22
  from .errors import FastApiAdapterError
@@ -2,19 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import logging
6
5
  from collections.abc import AsyncIterator, Iterator, Sequence
7
6
  from contextlib import asynccontextmanager, contextmanager
8
- from typing import Any, cast, get_args
7
+ from typing import Any
9
8
 
10
9
  from dishka.exceptions import NoFactoryError
11
10
  from fastapi import FastAPI
12
11
  from fastapi.exception_handlers import request_validation_exception_handler
13
12
  from fastapi.exceptions import RequestValidationError
14
- from pydantic_core.core_schema import ErrorType
15
13
  from starlette.middleware import Middleware
16
- from starlette.requests import Request
17
- from starlette.responses import JSONResponse
18
14
 
19
15
  from ..application import Application
20
16
  from ..application_services import ApplicationServiceValidationError
@@ -22,6 +18,16 @@ from ..authorization import PermissionDeniedError, UnauthenticatedError
22
18
  from ..errors import BusinessError, FrameworkError, ResourceConflictError, ResourceNotFoundError
23
19
  from ..lifecycle import ApplicationState
24
20
  from .contracts import RouterCatalog
21
+ from .error_handlers import (
22
+ _application_service_validation_handler,
23
+ _business_error_handler,
24
+ _framework_error_handler,
25
+ _permission_denied_handler,
26
+ _request_validation_handler,
27
+ _resource_conflict_handler,
28
+ _resource_not_found_handler,
29
+ _unauthenticated_handler,
30
+ )
25
31
  from .errors import FastApiAdapterError
26
32
  from .filters import ServiceFilter, _validate_filters
27
33
  from .middleware import DiMiddleware, _compile_middlewares
@@ -41,11 +47,8 @@ from .routing import (
41
47
  from .tracing import _install_tracing, _tracing_rollback
42
48
  from .upload_limits import UploadLimits
43
49
 
44
- _LOGGER = logging.getLogger(__name__)
45
50
  _INSTALLATION_MARKER = "_python_ddd_framework_fastapi_installed"
46
51
  _DISHKA_CONTAINER_BRIDGE = "dishka_container"
47
- _SAFE_REQUEST_LOCATION_ROOTS = frozenset({"body", "cookie", "header", "path", "query"})
48
- _TRUSTED_VALIDATION_ERROR_TYPES = frozenset(get_args(ErrorType))
49
52
 
50
53
 
51
54
  class FastApiAdapter:
@@ -296,112 +299,3 @@ def _installation_state(app: FastAPI) -> Iterator[None]:
296
299
  namespace.clear()
297
300
  namespace.update(snapshot)
298
301
  raise
299
-
300
-
301
- async def _business_error_handler(request: Request, error: Exception) -> JSONResponse:
302
- business_error = cast(BusinessError, error)
303
- return JSONResponse(
304
- status_code=400,
305
- content={
306
- "error": {
307
- "code": business_error.definition.code,
308
- "message": business_error.definition.message,
309
- "fields": [field.name for field in business_error.fields],
310
- **(
311
- {
312
- "data": business_error.data.model_dump(
313
- mode="json", by_alias=True, warnings="error"
314
- )
315
- }
316
- if business_error.data is not None
317
- else {}
318
- ),
319
- }
320
- },
321
- )
322
-
323
-
324
- async def _framework_error_handler(request: Request, error: Exception) -> JSONResponse:
325
- framework_error = cast(FrameworkError, error)
326
- _LOGGER.error(
327
- "Unhandled FrameworkError for %s %s",
328
- request.method,
329
- request.url.path,
330
- exc_info=(type(framework_error), framework_error, framework_error.__traceback__),
331
- )
332
- return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
333
-
334
-
335
- async def _application_service_validation_handler(
336
- request: Request,
337
- error: Exception,
338
- ) -> JSONResponse:
339
- validation_error = cast(ApplicationServiceValidationError, error)
340
- return JSONResponse(
341
- status_code=422,
342
- content={
343
- "detail": [
344
- {
345
- "loc": list(issue.location),
346
- "msg": issue.message,
347
- "type": issue.error_type,
348
- }
349
- for issue in validation_error.issues
350
- ]
351
- },
352
- )
353
-
354
-
355
- async def _request_validation_handler(request: Request, error: Exception) -> JSONResponse:
356
- validation_error = cast(RequestValidationError, error)
357
- return JSONResponse(
358
- status_code=422,
359
- content={
360
- "detail": [
361
- {
362
- "loc": list(_safe_request_validation_location(detail.get("loc"))),
363
- "msg": "Input validation failed",
364
- "type": _trusted_request_validation_error_type(detail.get("type")),
365
- }
366
- for detail in validation_error.errors()
367
- ]
368
- },
369
- )
370
-
371
-
372
- def _safe_request_validation_location(location: object) -> tuple[str, ...]:
373
- if (
374
- isinstance(location, tuple)
375
- and location
376
- and isinstance(location[0], str)
377
- and location[0] in _SAFE_REQUEST_LOCATION_ROOTS
378
- ):
379
- # 后续 segment 可能来自用户控制的 mapping key;只发布 FastAPI 自有位置类别。
380
- return (location[0],)
381
- return ()
382
-
383
-
384
- def _trusted_request_validation_error_type(error_type: object) -> str:
385
- if isinstance(error_type, str) and error_type in _TRUSTED_VALIDATION_ERROR_TYPES:
386
- return error_type
387
- return "value_error"
388
-
389
-
390
- async def _unauthenticated_handler(request: Request, error: Exception) -> JSONResponse:
391
- return JSONResponse(
392
- status_code=401,
393
- content={"detail": "Not authenticated"},
394
- headers={"WWW-Authenticate": "Bearer"},
395
- )
396
-
397
-
398
- async def _permission_denied_handler(request: Request, error: Exception) -> JSONResponse:
399
- return JSONResponse(status_code=403, content={"detail": "Forbidden"})
400
-
401
-
402
- async def _resource_conflict_handler(request: Request, error: Exception) -> JSONResponse:
403
- return JSONResponse(status_code=409, content={"detail": "Resource conflict"})
404
-
405
-
406
- async def _resource_not_found_handler(request: Request, error: Exception) -> JSONResponse:
407
- return JSONResponse(status_code=404, content={"detail": "Resource not found"})
@@ -1,76 +1,39 @@
1
- """ApplicationServiceCatalog 驱动的约定式 FastAPI route 生成。"""
1
+ """按正式 Module 暴露声明组装约定服务 Router。"""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import inspect
6
- import re
7
- import types
8
- from collections.abc import Awaitable, Callable, Coroutine, Mapping
9
- from dataclasses import dataclass, is_dataclass
10
- from datetime import date, datetime, time
11
- from decimal import Decimal
12
- from enum import Enum
13
- from typing import Annotated, Any, Literal, Union, cast, get_args, get_origin
14
- from uuid import UUID
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, cast
15
8
 
16
- from dishka import AsyncContainer
17
- from dishka.integrations.base import default_parse_dependency
18
- from dishka.integrations.fastapi import DishkaRoute, FromDishka
19
9
  from fastapi import APIRouter, Depends
20
- from fastapi.params import Body, Param, Path, Query
21
- from fastapi.params import Depends as DependsParameter
22
10
  from fastapi.security import HTTPBearer
23
- from pydantic import BaseModel
24
- from pydantic.fields import FieldInfo
25
- from starlette.requests import Request
26
- from starlette.responses import Response
27
11
  from starlette.routing import compile_path
28
12
 
29
13
  from ..application import Application
30
- from ..application_services import (
31
- ApplicationServiceRegistration,
32
- )
33
- from ..application_services.catalog import _CompiledApplicationService
34
- from ..application_services.dispatcher import _ApplicationServiceDispatcher
14
+ from ..application_services import ApplicationServiceRegistration
35
15
  from ..application_services.module import ApplicationServicesModule
36
- from ..diagnostics import SourceLocation
37
- from ..invocation.methods import _CompiledMethod
38
16
  from ..modularity import AppModule, ModuleDescriptor, ModuleKey
39
- from .action import _HttpActionOwner, _request_parameter
40
17
  from .contracts import (
41
18
  ApplicationServiceRouteOverride,
42
19
  _application_service_exposure_for,
43
20
  _ApplicationServiceExposure,
44
21
  )
45
22
  from .errors import FastApiAdapterError
46
- from .filters import _filters_for, _ordered_filters, _validate_filters
47
- from .http import _metadata, _remote, _service_segment
23
+ from .filters import _filters_for, _validate_filters
24
+ from .http import _remote, _service_segment
48
25
  from .http_router import _compile_parameter
49
- from .parameters import _HttpParameterValidator
50
- from .transfer import _manage_stream
51
- from .uploads import _prepare_upload
52
-
53
- _APPLICATION_SERVICE_SUFFIX = "ApplicationService"
54
- _PATH_PARAMETER = re.compile(r"\{(?P<name>[^{}:]+)(?::[^{}]+)?\}")
55
- _WORD_BOUNDARY_1 = re.compile(r"(.)([A-Z][a-z]+)")
56
- _WORD_BOUNDARY_2 = re.compile(r"([a-z0-9])([A-Z])")
57
- _ROUTE_SOURCE_ATTRIBUTE = "__python_ddd_framework_fastapi_route_source__"
58
- _INVOCATION_IDENTITY_ATTRIBUTE = "__python_ddd_framework_fastapi_invocation_identity__"
59
- _VERB_PREFIXES: tuple[tuple[str, str], ...] = (
60
- ("get_list", "GET"),
61
- ("get_all", "GET"),
62
- ("get", "GET"),
63
- ("create", "POST"),
64
- ("add", "POST"),
65
- ("insert", "POST"),
66
- ("post", "POST"),
67
- ("update", "PUT"),
68
- ("put", "PUT"),
69
- ("delete", "DELETE"),
70
- ("remove", "DELETE"),
71
- ("patch", "PATCH"),
26
+ from .service_endpoints import _ApplicationServiceRoute, _create_endpoint
27
+ from .service_routes import (
28
+ _conventional_verb,
29
+ _inspect_route_method,
30
+ _operation_id,
31
+ _query_model_parameter,
32
+ _route_path,
33
+ _route_projection,
34
+ _RouteMethod,
35
+ _service_path,
72
36
  )
73
- _SCALAR_TYPES = (str, int, float, bool, UUID, Decimal, date, datetime, time)
74
37
 
75
38
 
76
39
  @dataclass(frozen=True, slots=True)
@@ -79,78 +42,6 @@ class _GeneratedApplicationServiceRouter:
79
42
  router: APIRouter
80
43
 
81
44
 
82
- @dataclass(frozen=True, slots=True)
83
- class _RouteMethod:
84
- signature: inspect.Signature
85
- parameters: tuple[inspect.Parameter, ...]
86
-
87
-
88
- @dataclass(frozen=True, slots=True)
89
- class _ApplicationServiceRouteIdentity:
90
- service: _CompiledApplicationService
91
- method: _CompiledMethod
92
-
93
-
94
- class _ApplicationServiceRoute(DishkaRoute):
95
- """在 native FastAPI binding 前建立 catalog-identity invocation boundary。"""
96
-
97
- __slots__ = ("_compiled_method", "_compiled_service")
98
-
99
- def __init__(
100
- self,
101
- path: str,
102
- endpoint: Callable[..., Any],
103
- **kwargs: Any,
104
- ) -> None:
105
- identity = getattr(endpoint, _INVOCATION_IDENTITY_ATTRIBUTE, None)
106
- if not isinstance(identity, _ApplicationServiceRouteIdentity):
107
- raise FastApiAdapterError("generated Application Service route lost its identity")
108
- self._compiled_service = identity.service
109
- self._compiled_method = identity.method
110
- sources = getattr(endpoint, "__python_ddd_framework_http_sources__", None)
111
- super().__init__(path, endpoint, **kwargs)
112
- cast(Any, self.endpoint).__python_ddd_framework_http_sources__ = sources
113
- # include_router 会以已注入 endpoint 重建 route;显式把 identity 传递给下一次构造。
114
- setattr(self.endpoint, _INVOCATION_IDENTITY_ATTRIBUTE, identity)
115
-
116
- def get_route_handler(
117
- self,
118
- ) -> Callable[[Request], Coroutine[Any, Any, Response]]:
119
- native_handler = super().get_route_handler()
120
- service = self._compiled_service
121
- method = self._compiled_method
122
-
123
- async def route_handler(request: Request) -> Response:
124
- owner = await _HttpActionOwner.install(request)
125
- container = cast(AsyncContainer, request.state.dishka_container)
126
- dispatcher = await container.get(_ApplicationServiceDispatcher)
127
-
128
- async def bind() -> Response:
129
- await _prepare_upload(request, method.implementation_method, self)
130
- return await native_handler(request)
131
-
132
- try:
133
- result = await dispatcher.invoke_http_request(
134
- service,
135
- method,
136
- bind,
137
- is_short_circuited=lambda: owner.short_circuited,
138
- defer_audit=owner.audits.append,
139
- )
140
- owner.response_ready = isinstance(result, Response)
141
- except BaseException as error:
142
- owner.failure = error
143
- raise
144
- if not isinstance(result, Response):
145
- raise FastApiAdapterError(
146
- "an interceptor outside HTTP binding must return a native Response"
147
- )
148
- _manage_stream(request, result)
149
- return result
150
-
151
- return route_handler
152
-
153
-
154
45
  def _application_service_routers(
155
46
  application: Application,
156
47
  ) -> tuple[_GeneratedApplicationServiceRouter, ...]:
@@ -373,282 +264,6 @@ def _index_overrides(
373
264
  return indexed
374
265
 
375
266
 
376
- def _inspect_route_method(
377
- contract: type[object],
378
- method_name: str,
379
- signature: inspect.Signature,
380
- ) -> _RouteMethod:
381
- """在真实 FastAPI app 变更前解析并收窄动态 typing boundary。"""
382
-
383
- # 共享方法编译已解释本 Application 的扩展 DTO;HTTP 不另建字段权威。
384
- resolved_parameters = tuple(signature.parameters.values())
385
- parameters = resolved_parameters[1:]
386
- for parameter in parameters:
387
- if parameter.kind in (
388
- inspect.Parameter.POSITIONAL_ONLY,
389
- inspect.Parameter.VAR_POSITIONAL,
390
- inspect.Parameter.VAR_KEYWORD,
391
- ):
392
- raise FastApiAdapterError(
393
- f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
394
- "cannot use positional-only, *args, or **kwargs parameters"
395
- )
396
- if parameter.annotation is inspect.Signature.empty:
397
- raise FastApiAdapterError(
398
- f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
399
- f"parameter {parameter.name!r} requires a type annotation"
400
- )
401
- if signature.return_annotation is inspect.Signature.empty:
402
- raise FastApiAdapterError(
403
- f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
404
- "requires a return type annotation"
405
- )
406
- return _RouteMethod(signature=signature, parameters=parameters)
407
-
408
-
409
- def _query_model_parameter(parameter: inspect.Parameter, verb: str) -> inspect.Parameter:
410
- hint = parameter.annotation
411
- metadata = get_args(hint)[1:] if get_origin(hint) is Annotated else ()
412
- model = get_args(hint)[0] if metadata else hint
413
- if (
414
- verb == "GET"
415
- and isinstance(model, type)
416
- and issubclass(model, BaseModel)
417
- and not any(
418
- isinstance(value, (FieldInfo, DependsParameter))
419
- for value in (*metadata, parameter.default)
420
- )
421
- and default_parse_dependency(parameter, hint) is None
422
- ):
423
- # 仅补原生 Query 元数据;字段展开、校验和 OpenAPI 均交给 FastAPI/Pydantic。
424
- return parameter.replace(annotation=Annotated[hint, Query()])
425
- return parameter
426
-
427
-
428
- def _uses_non_path_binding(parameter: inspect.Parameter) -> bool:
429
- metadata = (
430
- get_args(parameter.annotation)[1:] if get_origin(parameter.annotation) is Annotated else ()
431
- )
432
- return any(
433
- isinstance(item, (Param, Body)) and not isinstance(item, Path)
434
- for item in (*metadata, parameter.default)
435
- )
436
-
437
-
438
- def _route_path(
439
- service_path: str,
440
- parameters: tuple[inspect.Parameter, ...],
441
- override_path: str | None,
442
- *,
443
- contract: type[object],
444
- method_name: str,
445
- ) -> str:
446
- # 只有唯一 required scalar id 才能无歧义进入 convention path;
447
- # 其余情况保持 query 或要求 override。
448
- id_candidates = tuple(
449
- parameter
450
- for parameter in parameters
451
- if _is_required_path_parameter(parameter)
452
- and (parameter.name == "id" or parameter.name.endswith("_id"))
453
- and _is_scalar_annotation(parameter.annotation)
454
- and not _uses_non_path_binding(parameter)
455
- )
456
- if override_path is None:
457
- if len(id_candidates) > 1:
458
- raise FastApiAdapterError(
459
- f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
460
- "has ambiguous required id parameters and requires an explicit path override"
461
- )
462
- path = service_path
463
- if len(id_candidates) == 1:
464
- path = f"{path}/{{{id_candidates[0].name}}}"
465
- # 只有集中定义的 CRUD 名称省略动作;审批/取消等领域动作保持其 contract 名称。
466
- if method_name not in {prefix for prefix, _ in _VERB_PREFIXES}:
467
- path = f"{path}/{method_name.replace('_', '-')}"
468
- return path
469
-
470
- if "?" in override_path or "#" in override_path or "//" in override_path:
471
- raise FastApiAdapterError("route override path cannot contain query or fragment syntax")
472
- rendered_parameters = tuple(
473
- match.group("name") for match in _PATH_PARAMETER.finditer(override_path)
474
- )
475
- if override_path.count("{") != len(rendered_parameters) or override_path.count("}") != len(
476
- rendered_parameters
477
- ):
478
- raise FastApiAdapterError(f"route override path {override_path!r} is malformed")
479
- parameters_by_name = {parameter.name: parameter for parameter in parameters}
480
- for name in rendered_parameters:
481
- parameter = parameters_by_name.get(name)
482
- if (
483
- parameter is None
484
- or not _is_required_path_parameter(parameter)
485
- or not _is_scalar_annotation(parameter.annotation)
486
- or _uses_non_path_binding(parameter)
487
- ):
488
- raise FastApiAdapterError(
489
- f"route override path parameter {name!r} must name a required scalar "
490
- f"Protocol parameter on {contract.__qualname__}.{method_name}"
491
- )
492
- if len(set(rendered_parameters)) != len(rendered_parameters):
493
- raise FastApiAdapterError(f"route override path {override_path!r} repeats a path parameter")
494
- return override_path
495
-
496
-
497
- def _is_required_path_parameter(parameter: inspect.Parameter) -> bool:
498
- return parameter.default is inspect.Signature.empty or isinstance(parameter.default, Path)
499
-
500
-
501
- def _is_scalar_annotation(annotation: object) -> bool:
502
- current = annotation
503
- if get_origin(current) is Annotated:
504
- current = get_args(current)[0]
505
- origin = get_origin(current)
506
- if origin in (types.UnionType, Union):
507
- members = tuple(item for item in get_args(current) if item is not type(None))
508
- return len(members) == 1 and _is_scalar_annotation(members[0])
509
- if origin is Literal:
510
- return all(isinstance(item, (str, int, float, bool)) for item in get_args(current))
511
- if not isinstance(current, type):
512
- return False
513
- if issubclass(current, BaseModel) or is_dataclass(current):
514
- return False
515
- return issubclass(current, (*_SCALAR_TYPES, Enum))
516
-
517
-
518
- def _service_path(root_path: str, contract: type[object], segment: str | None = None) -> str:
519
- service_name = contract.__name__
520
- if service_name.endswith(_APPLICATION_SERVICE_SUFFIX):
521
- service_name = service_name[: -len(_APPLICATION_SERVICE_SUFFIX)]
522
- if not service_name:
523
- raise FastApiAdapterError("application service contract has no routable name")
524
- return "/" + "/".join(
525
- part for part in (root_path, segment or _kebab_case(service_name)) if part
526
- )
527
-
528
-
529
- def _operation_id(contract: type[object], method_name: str) -> str:
530
- service_path = _service_path("", contract).rsplit("/", maxsplit=1)[-1]
531
- return f"{service_path.replace('-', '_')}_{method_name}"
532
-
533
-
534
- def _kebab_case(value: str) -> str:
535
- first_pass = _WORD_BOUNDARY_1.sub(r"\1-\2", value)
536
- return _WORD_BOUNDARY_2.sub(r"\1-\2", first_pass).lower().replace("_", "-")
537
-
538
-
539
- def _conventional_verb(method_name: str) -> str:
540
- for prefix, verb in _VERB_PREFIXES:
541
- if method_name == prefix or method_name.startswith(f"{prefix}_"):
542
- return verb
543
- return "POST"
544
-
545
-
546
- def _create_endpoint(
547
- service: _CompiledApplicationService,
548
- method: _CompiledMethod,
549
- route_method: _RouteMethod,
550
- source: SourceLocation,
551
- ) -> Callable[..., Awaitable[object]]:
552
- """保留 Protocol 原签名供 FastAPI binding,并注入同一 REQUEST dispatcher。"""
553
-
554
- contract = service.registration.contract
555
- method_name = method.name
556
- parameter_names = {parameter.name for parameter in route_method.parameters}
557
- dispatcher_parameter_name = "_python_ddd_framework_application_service_dispatcher"
558
- while dispatcher_parameter_name in parameter_names:
559
- dispatcher_parameter_name = f"_{dispatcher_parameter_name}"
560
-
561
- declared_filters = _ordered_filters(
562
- _filters_for(*reversed(service.registration.implementation.__mro__)),
563
- _filters_for(method.implementation_method),
564
- )
565
- validator = _HttpParameterValidator(
566
- route_method.signature.replace(parameters=route_method.parameters)
567
- )
568
-
569
- async def endpoint(**values: object) -> object:
570
- dispatcher = cast(_ApplicationServiceDispatcher, values.pop(dispatcher_parameter_name))
571
- request = cast(
572
- Request,
573
- values.pop(request_parameter_name)
574
- if injected_request
575
- else values[request_parameter_name],
576
- )
577
- owner: _HttpActionOwner = request.state.python_ddd_framework_action
578
- await owner.activate(
579
- method.descriptor.unit_of_work, method.name, service.registration.connection_name
580
- )
581
- filters = _ordered_filters(
582
- getattr(request.app.state, "python_ddd_framework_filters", ()), declared_filters
583
- )
584
- snapshots = validator.capture(values)
585
-
586
- async def invoke(actual: dict[str, object]) -> object:
587
- if filters:
588
- validator.validate_changes(actual, snapshots)
589
- return await dispatcher.invoke_http(service, method, actual)
590
-
591
- return await owner.invoke(
592
- method.implementation_method,
593
- values,
594
- filters,
595
- invoke,
596
- )
597
-
598
- request_parameter, injected_request = _request_parameter(route_method.parameters)
599
- request_parameter_name = request_parameter.name
600
- http_parameters = (
601
- (*route_method.parameters, request_parameter)
602
- if injected_request
603
- else route_method.parameters
604
- )
605
-
606
- dispatcher_parameter = inspect.Parameter(
607
- dispatcher_parameter_name,
608
- kind=inspect.Parameter.KEYWORD_ONLY,
609
- annotation=cast(Any, FromDishka)[_ApplicationServiceDispatcher],
610
- )
611
- # 动态 endpoint 的 signature/annotations 同时发布,兼容 FastAPI 与 postponed annotations。
612
- signature = route_method.signature.replace(parameters=(*http_parameters, dispatcher_parameter))
613
- endpoint_name = f"{contract.__name__}_{method_name}"
614
- endpoint.__name__ = endpoint_name
615
- endpoint.__qualname__ = endpoint_name
616
- endpoint.__module__ = contract.__module__
617
- cast(Any, endpoint).__signature__ = signature
618
- endpoint.__annotations__ = {
619
- **{
620
- parameter.name: parameter.annotation
621
- for parameter in (*http_parameters, dispatcher_parameter)
622
- },
623
- "return": signature.return_annotation,
624
- }
625
- setattr(endpoint, _ROUTE_SOURCE_ATTRIBUTE, source)
626
- setattr(
627
- endpoint,
628
- _INVOCATION_IDENTITY_ATTRIBUTE,
629
- _ApplicationServiceRouteIdentity(service, method),
630
- )
631
- return endpoint
632
-
633
-
634
- def _route_projection(
635
- method: object, override: ApplicationServiceRouteOverride | None
636
- ) -> tuple[dict[str, Any], dict[str, str]]:
637
- projection = dict(_metadata(method))
638
- provenance = {key: "method" for key in projection}
639
- if override is not None:
640
- from dataclasses import fields
641
-
642
- for field in fields(override):
643
- if field.name in ("service", "method", "enabled"):
644
- continue
645
- value = getattr(override, field.name)
646
- if value is not None:
647
- projection[field.name] = value
648
- provenance[field.name] = "module_override"
649
- return projection, provenance
650
-
651
-
652
267
  def _exposure_targets(
653
268
  http_descriptor: ModuleDescriptor,
654
269
  exposure: _ApplicationServiceExposure,