python-ddd-framework 0.5.1__py3-none-any.whl → 0.7.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 (164) hide show
  1. python_ddd_framework/application_services/bindings.py +0 -64
  2. python_ddd_framework/application_services/dispatcher.py +1 -1
  3. python_ddd_framework/application_services/invocation.py +7 -563
  4. python_ddd_framework/application_services/module.py +16 -5
  5. python_ddd_framework/authorization/checking.py +35 -0
  6. python_ddd_framework/authorization/module.py +1 -1
  7. python_ddd_framework/background_jobs/execution.py +1 -1
  8. python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
  9. python_ddd_framework/background_workers/execution.py +1 -1
  10. python_ddd_framework/background_workers/runtime.py +1 -1
  11. python_ddd_framework/caching/unit_of_work.py +2 -4
  12. python_ddd_framework/cli/project.py +11 -10
  13. python_ddd_framework/developer_kit/generation.py +44 -43
  14. python_ddd_framework/developer_kit/publication.py +442 -0
  15. python_ddd_framework/developer_kit/templates/module/cookiecutter.json +1 -1
  16. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +34 -44
  17. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +5 -72
  18. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +1 -3
  19. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +2 -3
  20. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +8 -62
  21. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +1 -2
  22. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +23 -8
  23. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +22 -10
  24. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/backend/src/host/main.py.jinja +4 -1
  25. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +55 -5
  26. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +233 -25
  27. python_ddd_framework/diagnostics/source.py +14 -1
  28. python_ddd_framework/domain/aggregates.py +5 -12
  29. python_ddd_framework/domain/event_values.py +124 -0
  30. python_ddd_framework/events/runtime.py +2 -7
  31. python_ddd_framework/events/unit_of_work.py +1 -4
  32. python_ddd_framework/fastapi/__init__.py +2 -0
  33. python_ddd_framework/fastapi/access.py +127 -0
  34. python_ddd_framework/fastapi/access_options.py +13 -0
  35. python_ddd_framework/fastapi/action.py +8 -1
  36. python_ddd_framework/fastapi/adapter.py +11 -117
  37. python_ddd_framework/fastapi/application_services.py +17 -402
  38. python_ddd_framework/fastapi/error_handlers.py +128 -0
  39. python_ddd_framework/fastapi/manual_action.py +33 -2
  40. python_ddd_framework/fastapi/module.py +2 -0
  41. python_ddd_framework/fastapi/realtime/authentication.py +21 -10
  42. python_ddd_framework/fastapi/realtime/options.py +0 -1
  43. python_ddd_framework/fastapi/realtime/runtime.py +42 -27
  44. python_ddd_framework/fastapi/request_context.py +12 -7
  45. python_ddd_framework/fastapi/routing.py +16 -15
  46. python_ddd_framework/fastapi/server.py +9 -0
  47. python_ddd_framework/fastapi/service_endpoints.py +187 -0
  48. python_ddd_framework/fastapi/service_routes.py +237 -0
  49. python_ddd_framework/fastapi/streaming.py +95 -0
  50. python_ddd_framework/fastapi/transfer.py +139 -4
  51. python_ddd_framework/hosted_services/bridge.py +2 -6
  52. python_ddd_framework/hosted_services/runtime.py +1 -1
  53. python_ddd_framework/identity/__init__.py +21 -13
  54. python_ddd_framework/identity/application.py +12 -66
  55. python_ddd_framework/identity/contracts.py +38 -411
  56. python_ddd_framework/identity/domain_rules.py +17 -0
  57. python_ddd_framework/identity/dtos.py +152 -0
  58. python_ddd_framework/identity/extensions.py +9 -0
  59. python_ddd_framework/identity/management.py +28 -25
  60. python_ddd_framework/identity/models.py +86 -0
  61. python_ddd_framework/identity/module.py +3 -4
  62. python_ddd_framework/identity/options.py +54 -0
  63. python_ddd_framework/identity/seeding.py +59 -0
  64. python_ddd_framework/identity/services.py +1 -1
  65. python_ddd_framework/identity/sqlalchemy/module.py +9 -6
  66. python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
  67. python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
  68. python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
  69. python_ddd_framework/identity/stores.py +120 -0
  70. python_ddd_framework/identity/tokens.py +1 -1
  71. python_ddd_framework/invocation/callables.py +1 -1
  72. python_ddd_framework/invocation/dispatcher.py +3 -25
  73. python_ddd_framework/invocation/entrypoints.py +2 -1
  74. python_ddd_framework/invocation/function_runtime.py +2 -2
  75. python_ddd_framework/invocation/interception.py +1 -1
  76. python_ddd_framework/invocation/managed_proxy.py +2 -1
  77. python_ddd_framework/invocation/managed_services.py +8 -14
  78. python_ddd_framework/invocation/methods.py +2 -7
  79. python_ddd_framework/invocation/module.py +1 -1
  80. python_ddd_framework/invocation/runtime.py +483 -0
  81. python_ddd_framework/invocation/scopes.py +93 -0
  82. python_ddd_framework/messaging/channel.py +1 -1
  83. python_ddd_framework/messaging/runtime.py +1 -1
  84. python_ddd_framework/modularity/discovery.py +2 -7
  85. python_ddd_framework/modularity/graph.py +2 -8
  86. python_ddd_framework/observability/formatting.py +1 -0
  87. python_ddd_framework/observability/logging.py +1 -1
  88. python_ddd_framework/observability/tracing.py +1 -1
  89. python_ddd_framework/redis/cache.py +2 -0
  90. python_ddd_framework/redis/notification_runtime.py +1 -1
  91. python_ddd_framework/services/arbitration.py +1 -20
  92. python_ddd_framework/services/composition.py +71 -2
  93. python_ddd_framework/services/provider.py +23 -1
  94. python_ddd_framework/services/relocation.py +96 -0
  95. python_ddd_framework/settings/refresh.py +1 -1
  96. python_ddd_framework/settings/sqlalchemy/store.py +1 -1
  97. python_ddd_framework/sqlalchemy/metadata.py +11 -413
  98. python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
  99. python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
  100. python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
  101. python_ddd_framework/sqlalchemy/migration.py +2 -1
  102. python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
  103. python_ddd_framework/sqlalchemy/module.py +2 -1
  104. python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
  105. python_ddd_framework/unit_of_work/contracts.py +68 -7
  106. python_ddd_framework/unit_of_work/manager.py +15 -9
  107. python_ddd_framework/unit_of_work/module.py +1 -1
  108. {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/METADATA +101 -10
  109. {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/RECORD +113 -141
  110. {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/WHEEL +1 -1
  111. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/__init__.py.jinja +0 -1
  112. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/handler.py.jinja +0 -45
  113. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/payload.py.jinja +0 -8
  114. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/__init__.py.jinja +0 -1
  115. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/handler.py.jinja +0 -35
  116. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/payload.py.jinja +0 -5
  117. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/schedule.py.jinja +0 -15
  118. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_maintenance_worker.py.jinja +0 -28
  119. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_statistics_worker.py.jinja +0 -26
  120. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/order_cache.py.jinja +0 -9
  121. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/statistics_cache.py.jinja +0 -9
  122. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/order_changed_handler.py.jinja +0 -23
  123. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +0 -71
  124. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +0 -16
  125. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/order_reporting_service.py.jinja +0 -16
  126. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/order_timing_interceptor.py.jinja +0 -17
  127. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options/order_options.py.jinja +0 -7
  128. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_approval_service.py.jinja +0 -65
  129. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_management_service.py.jinja +0 -35
  130. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_query_service.py.jinja +0 -41
  131. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/approval_setting_observer.py.jinja +0 -17
  132. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/approve_order.py.jinja +0 -6
  133. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/create_order.py.jinja +0 -7
  134. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/order_reporting_service.py.jinja +0 -7
  135. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_approval_service.py.jinja +0 -13
  136. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_management_service.py.jinja +0 -10
  137. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_query_service.py.jinja +0 -12
  138. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_statistics_snapshot.py.jinja +0 -9
  139. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_view.py.jinja +0 -12
  140. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/entities/order.py.jinja +0 -48
  141. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/order_changed.py.jinja +0 -13
  142. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/order_repository.py.jinja +0 -17
  143. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/order_seed_contributor.py.jinja +0 -22
  144. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/order_approval_service.py.jinja +0 -23
  145. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings/approval_settings.py.jinja +0 -17
  146. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/order_title.py.jinja +0 -10
  147. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/order_constants.py.jinja +0 -3
  148. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/order_status.py.jinja +0 -6
  149. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/order_errors.py.jinja +0 -11
  150. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_messages.py.jinja +0 -26
  151. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_observation.py.jinja +0 -13
  152. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permission_provider.py.jinja +0 -19
  153. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permissions.py.jinja +0 -7
  154. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/money.py.jinja +0 -18
  155. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/export_filter.py.jinja +0 -15
  156. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/refresh_orders.py.jinja +0 -5
  157. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/routers/order_files.py.jinja +0 -76
  158. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/order_socket.py.jinja +0 -44
  159. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/order_model.py.jinja +0 -29
  160. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +0 -79
  161. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +0 -19
  162. {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/entry_points.txt +0 -0
  163. {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/LICENSE +0 -0
  164. {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
@@ -9,15 +9,12 @@ from typing import get_origin
9
9
  from dishka import (
10
10
  DEFAULT_COMPONENT,
11
11
  DependencyKey,
12
- Provider,
13
12
  Scope,
14
13
  )
15
14
 
16
15
  from ..diagnostics import SourceLocation
17
16
  from ..modularity import ModuleRegistry
18
17
  from ..services.arbitration import (
19
- _ArbitrationResult,
20
- _provider_with_active_sources,
21
18
  _RegistrationDraft,
22
19
  )
23
20
  from ..services.native_graph import _NativeServiceGraph
@@ -201,64 +198,3 @@ def _validate_application_service_bindings(
201
198
  "or private implementation binding"
202
199
  ),
203
200
  )
204
-
205
-
206
- def _runtime_providers(
207
- arbitration: _ArbitrationResult,
208
- services: tuple[_BoundApplicationService, ...],
209
- ) -> tuple[Provider, ...]:
210
- """仅重定位 Application Service factories;其他 Dishka provider 保持原生形状。"""
211
-
212
- related_draft_ids = frozenset(
213
- draft_id for service in services for draft_id in service.related_draft_ids
214
- )
215
- drafts_by_entry: dict[int, list[_RegistrationDraft]] = {}
216
- for draft in arbitration.drafts:
217
- drafts_by_entry.setdefault(draft.entry.entry_id, []).append(draft)
218
-
219
- providers: list[Provider] = []
220
- for entry in arbitration.ordered_entries:
221
- entry_drafts = drafts_by_entry[entry.entry_id]
222
- active = tuple(
223
- draft
224
- for draft in entry_drafts
225
- if draft.decision is not RegistrationDecision.BACKED_OFF
226
- and id(draft) not in related_draft_ids
227
- )
228
- if not active:
229
- continue
230
- if len(active) == len(entry_drafts):
231
- providers.append(entry.contribution.provider)
232
- else:
233
- providers.append(_provider_with_active_sources(entry.contribution.provider, active))
234
-
235
- # default component 只发布 REQUEST proxy;ACTION implementation 统一藏入框架私有 component。
236
- implementation_provider = Provider(component=_APPLICATION_SERVICE_COMPONENT)
237
- for service in services:
238
- winner = service.winner
239
- assert winner.factory is not None
240
- # internal contributor 被 override 时仍以原声明 key 调度;
241
- # winner class 只是实现事实,不能改写契约 key。
242
- contract = (
243
- service.binding.discovery.definition.contract or service.binding.discovery.service_type
244
- )
245
- implementation_provider.factories.append(
246
- winner.factory.replace(
247
- dependencies=tuple(
248
- dependency.with_component(DEFAULT_COMPONENT)
249
- for dependency in winner.factory.dependencies
250
- ),
251
- kw_dependencies={
252
- name: dependency.with_component(DEFAULT_COMPONENT)
253
- for name, dependency in winner.factory.kw_dependencies.items()
254
- },
255
- provides=DependencyKey(contract, None),
256
- when_override=None,
257
- when_component=DEFAULT_COMPONENT,
258
- )
259
- )
260
- # ServiceCatalog 的 accepted row 必须描述 relocated native binding,而非旧 default key。
261
- winner.key = DependencyKey(contract, _APPLICATION_SERVICE_COMPONENT)
262
- if services:
263
- providers.append(implementation_provider)
264
- return tuple(providers)
@@ -13,6 +13,7 @@ from dishka import AsyncContainer
13
13
  from ..invocation.dispatcher import _MethodDispatcher
14
14
  from ..invocation.execution import _InterceptorPipeline
15
15
  from ..invocation.methods import _CompiledMethod
16
+ from ..invocation.runtime import _current_task, _InvocationRuntime
16
17
  from ..settings.provider import SettingProvider
17
18
  from .catalog import (
18
19
  _APPLICATION_SERVICE_COMPONENT,
@@ -22,7 +23,6 @@ from .contracts import ApplicationService
22
23
  from .errors import (
23
24
  CrossTaskInvocationError,
24
25
  )
25
- from .invocation import _current_task, _InvocationRuntime
26
26
 
27
27
 
28
28
  @dataclass(slots=True)
@@ -1,563 +1,14 @@
1
- """Application-local admission、调用身份、REQUEST 入场与现有 ACTION/UoW owner。"""
1
+ """应用服务 Contract 的 REQUEST 代理入口;准入与 scope 清理由 Invocation 拥有。"""
2
2
 
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import logging
7
- import threading
8
- from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
9
- from contextlib import asynccontextmanager, contextmanager
10
- from contextvars import ContextVar, Token
11
- from dataclasses import dataclass
12
- from typing import TYPE_CHECKING, cast
13
- from uuid import UUID, uuid4
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import cast
14
5
 
15
6
  from dishka import AsyncContainer, Scope
16
7
 
17
8
  from ..authorization import CurrentUser
18
- from ..invocation import ApplicationInvocationContext
19
- from ..invocation.contracts import _is_safe_correlation_id
20
- from ..lifecycle import ApplicationState, RuntimeParticipant, ShutdownReason
21
- from ..unit_of_work import UnitOfWork, UnitOfWorkManager
22
- from ..unit_of_work.options import _DEFAULT_CONNECTION_NAME, UnitOfWorkDeclaration
23
- from .catalog import (
24
- ApplicationServiceCatalog,
25
- )
26
- from .errors import (
27
- ApplicationInvocationCleanupError,
28
- ApplicationInvocationRejectedError,
29
- CrossTaskInvocationError,
30
- NestedConnectionError,
31
- )
32
-
33
- _LOGGER = logging.getLogger(__name__)
34
-
35
- if TYPE_CHECKING:
36
- from opentelemetry.trace import Tracer
37
-
38
-
39
- @dataclass(slots=True)
40
- class _InvocationState:
41
- context: ApplicationInvocationContext
42
- owner_task: asyncio.Task[object]
43
- action_container: AsyncContainer
44
-
45
-
46
- @dataclass(frozen=True, slots=True)
47
- class _CallerContext:
48
- current_user: CurrentUser
49
- correlation_id: str
50
- owner_task: asyncio.Task[object]
51
- transferable: bool = False
52
-
53
-
54
- @dataclass(frozen=True, slots=True)
55
- class _OuterInvocationLease:
56
- owner_task: asyncio.Task[object]
57
- allows_invocation: bool
58
-
59
-
60
- @dataclass(frozen=True, slots=True)
61
- class _CallScope:
62
- container: AsyncContainer
63
- owner_task: asyncio.Task[object]
64
-
65
-
66
- @dataclass(eq=False, slots=True)
67
- class _InvocationReservation:
68
- """只携带准入事实,不携带 caller、DI scope 或事务;由接受工作的 owner 收口。"""
69
-
70
- runtime: _InvocationRuntime
71
- executing: bool = False
72
-
73
-
74
- @dataclass(eq=False, frozen=True, slots=True)
75
- class _StoppingPermission:
76
- """单 Application、服务 owner 与单次收尾钩子的准入身份,不携带业务 scope。"""
77
-
78
- runtime: _InvocationRuntime
79
- owner: type[object]
80
-
81
-
82
- class _InvocationRuntime:
83
- """每个 Application 独占 admission 与 ContextVar,禁止进程级 current app。"""
84
-
85
- __slots__ = (
86
- "_active",
87
- "_admission_lock",
88
- "_ambient",
89
- "_application_id",
90
- "_authenticating",
91
- "_call_scope",
92
- "_caller",
93
- "_changed",
94
- "_http_method",
95
- "_is_accepting",
96
- "_lease",
97
- "_loop",
98
- "_permissions",
99
- "_reservations",
100
- "_state_getter",
101
- "tracer",
102
- )
103
-
104
- def __init__(
105
- self,
106
- *,
107
- application_id: UUID,
108
- state_getter: Callable[[], ApplicationState],
109
- ) -> None:
110
- self._application_id = application_id
111
- self._authenticating: ContextVar[bool] = ContextVar(
112
- f"python_ddd_framework_authenticating_{application_id}", default=False
113
- )
114
- self._http_method: ContextVar[str | None] = ContextVar(
115
- f"python_ddd_framework_http_method_{application_id}", default=None
116
- )
117
- self.tracer: Tracer | None = None
118
- self._state_getter = state_getter
119
- self._ambient: ContextVar[_InvocationState | None] = ContextVar(
120
- f"python_ddd_framework_invocation_{application_id}",
121
- default=None,
122
- )
123
- self._caller: ContextVar[_CallerContext | None] = ContextVar(
124
- f"python_ddd_framework_caller_{application_id}",
125
- default=None,
126
- )
127
- self._lease: ContextVar[_OuterInvocationLease | None] = ContextVar(
128
- f"python_ddd_framework_invocation_lease_{application_id}",
129
- default=None,
130
- )
131
- self._admission_lock = threading.RLock()
132
- self._changed = asyncio.Event()
133
- self._loop: asyncio.AbstractEventLoop | None = None
134
- self._permissions: set[_StoppingPermission] = set()
135
- self._is_accepting = False
136
- self._active = 0
137
- self._reservations: set[_InvocationReservation] = set()
138
- self._call_scope: ContextVar[_CallScope | None] = ContextVar(
139
- f"python_ddd_framework_call_scope_{application_id}",
140
- default=None,
141
- )
142
-
143
- @contextmanager
144
- def call_scope(self, container: AsyncContainer) -> Iterator[None]:
145
- token = self._call_scope.set(_CallScope(container, _current_task()))
146
- try:
147
- yield
148
- finally:
149
- self._call_scope.reset(token)
150
-
151
- def current_call_scope(self) -> AsyncContainer | None:
152
- scope = self._call_scope.get()
153
- if scope is None:
154
- return None
155
- if scope.owner_task is not _current_task():
156
- raise CrossTaskInvocationError
157
- return scope.container
158
-
159
- def http_method(self) -> str | None:
160
- return self._http_method.get()
161
-
162
- @contextmanager
163
- def http_scope(self, method: str) -> Iterator[None]:
164
- token = self._http_method.set(method)
165
- try:
166
- yield
167
- finally:
168
- self._http_method.reset(token)
169
-
170
- @contextmanager
171
- def detached_execution(self, container: AsyncContainer) -> Iterator[None]:
172
- token = self._ambient.set(None)
173
- try:
174
- with self.call_scope(container):
175
- yield
176
- finally:
177
- self._ambient.reset(token)
178
-
179
- @property
180
- def application_id(self) -> UUID:
181
- return self._application_id
182
-
183
- def start_accepting(self) -> None:
184
- self._loop = asyncio.get_running_loop()
185
- self._is_accepting = True
186
-
187
- def runtime_participant(self) -> RuntimeParticipant:
188
- async def start() -> None:
189
- self.start_accepting()
190
-
191
- async def stop(reason: ShutdownReason) -> tuple[BaseException, ...]:
192
- self.close_admission()
193
- return ()
194
-
195
- return RuntimeParticipant(
196
- start=start,
197
- stop=stop,
198
- quiesce=self.quiesce,
199
- close_admission=self.close_admission,
200
- drain=self.stop_accepting_and_wait,
201
- )
202
-
203
- def quiesce(self) -> None:
204
- with self._admission_lock:
205
- self._is_accepting = False
206
-
207
- def close_admission(self) -> None:
208
- with self._admission_lock:
209
- self._is_accepting = False
210
- self._permissions.clear()
211
-
212
- def grant_stopping(self, owner: type[object]) -> _StoppingPermission:
213
- with self._admission_lock:
214
- if self._state_getter() not in (
215
- ApplicationState.STOPPING,
216
- ApplicationState.ROLLING_BACK,
217
- ):
218
- raise ApplicationInvocationRejectedError(reason="Not in the stopping phase")
219
- permission = _StoppingPermission(self, owner)
220
- self._permissions.add(permission)
221
- return permission
222
-
223
- def revoke_stopping(self, permission: _StoppingPermission) -> None:
224
- with self._admission_lock:
225
- self._permissions.discard(permission)
226
-
227
- def require_stopping(self, permission: _StoppingPermission) -> None:
228
- with self._admission_lock:
229
- if permission.runtime is not self or permission not in self._permissions:
230
- raise ApplicationInvocationRejectedError(reason="Invalid stopping permission")
231
-
232
- async def stop_accepting_and_wait(self) -> None:
233
- self.close_admission()
234
- while True:
235
- self._changed.clear()
236
- with self._admission_lock:
237
- if self._active == 0 and not self._reservations:
238
- return
239
- await self._changed.wait()
240
-
241
- def reserve(self, permission: _StoppingPermission | None = None) -> _InvocationReservation:
242
- # SDK thread 与权限撤销在同一锁下线性化;已接受许可独立于钩子权限的寿命。
243
- with self._admission_lock:
244
- if permission is None:
245
- self.require_accepting_work()
246
- else:
247
- self.require_stopping(permission)
248
- reservation = _InvocationReservation(self)
249
- self._reservations.add(reservation)
250
- return reservation
251
-
252
- def require_accepting_work(self) -> None:
253
- if not self._is_accepting or self._state_getter() not in (
254
- ApplicationState.STARTING,
255
- ApplicationState.RUNNING,
256
- ):
257
- raise ApplicationInvocationRejectedError(reason="Application is not accepting work")
258
-
259
- async def discard(self, reservation: _InvocationReservation) -> None:
260
- self.release_reservation(reservation)
261
-
262
- def release_reservation(self, reservation: _InvocationReservation) -> None:
263
- with self._admission_lock:
264
- if reservation.runtime is not self or reservation not in self._reservations:
265
- raise ApplicationInvocationRejectedError(reason="Invalid or released reservation")
266
- self._reservations.remove(reservation)
267
- if self._loop is not None and not self._loop.is_closed():
268
- try:
269
- self._loop.call_soon_threadsafe(self._changed.set)
270
- except RuntimeError:
271
- # 外部 submit 可能在 loop 关闭竞争中退回许可;此时已无 loop 排空等待者。
272
- if not self._loop.is_closed():
273
- raise
274
-
275
- @asynccontextmanager
276
- async def reserved_lease(self, reservation: _InvocationReservation) -> AsyncIterator[None]:
277
- if (
278
- reservation.runtime is not self
279
- or reservation not in self._reservations
280
- or reservation.executing
281
- or self._lease.get() is not None
282
- or self.current() is not None
283
- or self.current_call_scope() is not None
284
- ):
285
- raise ApplicationInvocationRejectedError(reason="Invalid reservation execution owner")
286
- reservation.executing = True
287
- token = self._lease.set(_OuterInvocationLease(_current_task(), True))
288
- try:
289
- yield
290
- finally:
291
- self._lease.reset(token)
292
- await self.discard(reservation)
293
-
294
- @asynccontextmanager
295
- async def outer_lease(self, *, allow_composed_request: bool = False) -> AsyncIterator[None]:
296
- owner_task = _current_task()
297
- inherited = self._lease.get()
298
- if inherited is not None:
299
- if inherited.owner_task is not owner_task:
300
- # asyncio.create_task 会复制 ContextVar;child task 不得继承 parent admission。
301
- raise CrossTaskInvocationError
302
- if not inherited.allows_invocation and not allow_composed_request:
303
- raise ApplicationInvocationRejectedError(
304
- reason=(
305
- "Application must be running and accepting invocations, got "
306
- f"{self._state_getter().value}"
307
- )
308
- )
309
- yield
310
- return
311
-
312
- with self._admission_lock:
313
- state = self._state_getter()
314
- # HostedService.start 可调用业务入口,但全部后台启动成功前 readiness 仍为 false。
315
- allows_invocation = self._is_accepting and state in (
316
- ApplicationState.STARTING,
317
- ApplicationState.RUNNING,
318
- )
319
- allows_composed_request = allow_composed_request and state is ApplicationState.COMPOSED
320
- if not allows_invocation and not allows_composed_request:
321
- raise ApplicationInvocationRejectedError(
322
- reason=(
323
- f"Application must be running and accepting invocations, got {state.value}"
324
- )
325
- )
326
- self._active += 1
327
- token = self._lease.set(_OuterInvocationLease(owner_task, allows_invocation))
328
- try:
329
- yield
330
- finally:
331
- self._lease.reset(token)
332
- with self._admission_lock:
333
- assert self._active > 0
334
- self._active -= 1
335
- if self._active == 0:
336
- self._changed.set()
337
-
338
- def current(self) -> _InvocationState | None:
339
- return self._ambient.get()
340
-
341
- def bind(self, state: _InvocationState) -> Token[_InvocationState | None]:
342
- return self._ambient.set(state)
343
-
344
- def reset(self, token: Token[_InvocationState | None]) -> None:
345
- self._ambient.reset(token)
346
-
347
- def current_user(self) -> CurrentUser:
348
- if self._authenticating.get():
349
- raise ApplicationInvocationRejectedError(
350
- reason="authentication cannot depend on the final CurrentUser"
351
- )
352
- caller = self._caller.get()
353
- if caller is None:
354
- return CurrentUser.anonymous()
355
- if not caller.transferable and caller.owner_task is not _current_task():
356
- raise CrossTaskInvocationError
357
- return caller.current_user
358
-
359
- def correlation_id(self) -> str | None:
360
- caller = self._caller.get()
361
- if caller is None:
362
- return None
363
- if not caller.transferable and caller.owner_task is not _current_task():
364
- raise CrossTaskInvocationError
365
- return caller.correlation_id
366
-
367
- def diagnostic_correlation_id(self) -> str | None:
368
- # 日志允许只读关联复制到线程的上下文;这不授予 child task/线程调用业务的 lease。
369
- caller = self._caller.get()
370
- return None if caller is None else caller.correlation_id
371
-
372
- def bind_caller(
373
- self,
374
- current_user: CurrentUser,
375
- correlation_id: str,
376
- ) -> Token[_CallerContext | None]:
377
- if not isinstance(current_user, CurrentUser):
378
- raise TypeError("current_user must be CurrentUser")
379
- if not _is_safe_correlation_id(correlation_id):
380
- raise ValueError("correlation_id must be a safe non-empty identifier")
381
- return self._caller.set(
382
- _CallerContext(
383
- current_user=current_user,
384
- correlation_id=correlation_id,
385
- owner_task=_current_task(),
386
- )
387
- )
388
-
389
- def reset_caller(self, token: Token[_CallerContext | None]) -> None:
390
- self._caller.reset(token)
391
-
392
- @contextmanager
393
- def authenticating(self) -> Iterator[None]:
394
- token = self._authenticating.set(True)
395
- try:
396
- yield
397
- finally:
398
- self._authenticating.reset(token)
399
-
400
- @contextmanager
401
- def trusted_http_task(
402
- self, container: AsyncContainer, user: CurrentUser, correlation_id: str, method: str
403
- ) -> Iterator[None]:
404
- # 只有 adapter 的受监督入口可交接许可;普通 create_task 仍受原有 task guard 约束。
405
- lease = self._lease.get()
406
- if lease is None or self.current() is not None:
407
- raise CrossTaskInvocationError
408
- task = _current_task()
409
- lease_token = self._lease.set(_OuterInvocationLease(task, lease.allows_invocation))
410
- caller_token = self._caller.set(_CallerContext(user, correlation_id, task, True))
411
- try:
412
- with self.call_scope(container), self.http_scope(method):
413
- yield
414
- finally:
415
- self._caller.reset(caller_token)
416
- self._lease.reset(lease_token)
417
-
418
- def context_for_action(self, container: AsyncContainer) -> ApplicationInvocationContext:
419
- """由原生 ACTION factory 缓存上下文,DI 与调用管线共享同一身份。"""
420
- # Dishka 1.10 的公开 context 属性已弃用;这里只读取原生 scope 的输入,
421
- # 不写 context/cache,也不复制容器的解析或缓存。第三方字段访问集中于此。
422
- native_context = container._context
423
- if native_context is None or ApplicationInvocationContext not in native_context:
424
- return self._new_context(_DEFAULT_CONNECTION_NAME)
425
- supplied: object = native_context[ApplicationInvocationContext]
426
- if not isinstance(supplied, ApplicationInvocationContext):
427
- raise ApplicationInvocationRejectedError(reason="invalid ACTION invocation context")
428
- if supplied.application_id != self.application_id:
429
- raise ApplicationInvocationRejectedError(
430
- reason="ACTION invocation context belongs to another Application"
431
- )
432
- return supplied
433
-
434
- def _new_context(self, connection_name: str) -> ApplicationInvocationContext:
435
- return ApplicationInvocationContext(
436
- application_id=self.application_id,
437
- invocation_id=uuid4(),
438
- parent_invocation_id=None,
439
- connection_name=connection_name,
440
- )
441
-
442
- async def run_action(
443
- self,
444
- container: AsyncContainer,
445
- *,
446
- connection_name: str,
447
- callback: Callable[[AsyncContainer], Awaitable[object]],
448
- declaration: UnitOfWorkDeclaration,
449
- method_name: str = "execute",
450
- unit_of_work_enabled: bool = True,
451
- ) -> object:
452
- """执行 scope 独立于数据库 UoW;自动边界只完成自己创建或加入的工作。"""
453
- context = (
454
- await container.get(ApplicationInvocationContext)
455
- if container.scope is Scope.ACTION
456
- else self._new_context(connection_name)
457
- )
458
- if context.connection_name != connection_name:
459
- raise NestedConnectionError(outer=context.connection_name, requested=connection_name)
460
- work: UnitOfWork | None = None
461
- async with _managed_action(
462
- container, context, committed=lambda: work is not None and work.committed
463
- ) as action:
464
- state = _InvocationState(context, _current_task(), action)
465
- token = self.bind(state)
466
- try:
467
- if not unit_of_work_enabled:
468
- return await callback(action)
469
- manager = await action.get(UnitOfWorkManager)
470
- if declaration.disabled:
471
- manager._check_current(connection_name)
472
- return await callback(action)
473
- options = manager._resolve_options(connection_name, declaration, method_name)
474
- async with manager._begin(options) as work:
475
- result = await callback(action)
476
- await work.complete()
477
- return result
478
- finally:
479
- self.reset(token)
480
-
481
-
482
- @asynccontextmanager
483
- async def _managed_request(parent: AsyncContainer) -> AsyncIterator[AsyncContainer]:
484
- """排空入口自己创建的 REQUEST;不推断业务事务是否已经提交。"""
485
- request = parent(scope=Scope.REQUEST)
486
- primary: BaseException | None = None
487
- try:
488
- yield request
489
- except BaseException as error:
490
- primary = error
491
- primary, failures = await _close_action(request, primary)
492
- if failures:
493
- # REQUEST 无事务 owner;保留业务原异常及已有 cause,不伪造 committed=False。
494
- inherited = (primary.__cause__,) if primary is not None and primary.__cause__ else ()
495
- cleanup = BaseExceptionGroup("REQUEST cleanup failed", (*inherited, *failures))
496
- if primary is not None:
497
- raise primary from cleanup
498
- raise cleanup
499
- if primary is not None:
500
- raise primary
501
-
502
-
503
- @asynccontextmanager
504
- async def _managed_action(
505
- parent: AsyncContainer,
506
- context: ApplicationInvocationContext,
507
- *,
508
- committed: Callable[[], bool],
509
- ) -> AsyncIterator[AsyncContainer]:
510
- if parent.scope is Scope.ACTION:
511
- # 普通服务可能已经由调用方的 ACTION 解析;资源仍由该 scope 的 owner 关闭。
512
- yield parent
513
- return
514
- action = parent(context={ApplicationInvocationContext: context}, scope=Scope.ACTION)
515
- primary: BaseException | None = None
516
- try:
517
- yield action
518
- except BaseException as error:
519
- primary = error
520
- primary, failures = await _close_action(action, primary)
521
- _raise_scope_failure(primary, failures, committed())
522
-
523
-
524
- async def _close_action(
525
- action: AsyncContainer, primary: BaseException | None
526
- ) -> tuple[BaseException | None, tuple[BaseException, ...]]:
527
- # Dishka generator 可能拥有 ContextVar token,进入与退出必须保持同一 task。
528
- # 具体 provider 负责保护自己的原生 I/O(SQL UoW 已在 scope 关闭前回收);
529
- # finalizer 的 BaseException 仍需继续原生 close,穷尽其余资源。
530
- failures: list[BaseException] = []
531
- while True:
532
- try:
533
- await action.close(primary)
534
- return primary, tuple(failures)
535
- except asyncio.CancelledError as cancellation:
536
- if primary is None:
537
- primary = cancellation
538
- else:
539
- failures.append(cancellation)
540
- except BaseException as error:
541
- failures.append(error)
542
-
543
-
544
- def _raise_scope_failure(
545
- primary: BaseException | None, failures: tuple[BaseException, ...], committed: bool
546
- ) -> None:
547
- if failures:
548
- inherited = primary.__cause__ if isinstance(primary, asyncio.CancelledError) else primary
549
- if isinstance(inherited, ApplicationInvocationCleanupError):
550
- failures = (*inherited.cleanup_failures, *failures)
551
- if not isinstance(primary, asyncio.CancelledError):
552
- primary = inherited.primary
553
- failure = ApplicationInvocationCleanupError(
554
- primary=primary, cleanup_failures=failures, committed=committed
555
- )
556
- if isinstance(primary, asyncio.CancelledError):
557
- raise primary from failure
558
- raise failure from (primary or failures[0])
559
- if primary is not None:
560
- raise primary
9
+ from ..invocation.runtime import _InvocationRuntime
10
+ from ..invocation.scopes import _managed_request
11
+ from .catalog import ApplicationServiceCatalog
561
12
 
562
13
 
563
14
  class _ScopedApplicationServiceInvoker:
@@ -589,7 +40,7 @@ class _ScopedApplicationServiceInvoker:
589
40
  async with self._runtime.outer_lease():
590
41
  caller_token = self._runtime.bind_caller(current_user, correlation_id)
591
42
  try:
592
- async with parent(scope=Scope.REQUEST) as request_container:
43
+ async with _managed_request(parent) as request_container:
593
44
  proxy = await request_container.get(service.registration.contract)
594
45
  call = cast(
595
46
  Callable[..., Awaitable[object]],
@@ -604,10 +55,3 @@ class _ScopedApplicationServiceInvoker:
604
55
  return
605
56
  if parent.scope is not Scope.SESSION or parent.parent_container is not self._root_container:
606
57
  raise RuntimeError("scoped invocation parent is not owned by this Application")
607
-
608
-
609
- def _current_task() -> asyncio.Task[object]:
610
- task = asyncio.current_task()
611
- if task is None:
612
- raise RuntimeError("Application Service invocation requires an asyncio Task")
613
- return cast(asyncio.Task[object], task)