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
@@ -8,6 +8,7 @@ import functools
8
8
  import inspect
9
9
  import threading
10
10
  from collections.abc import Awaitable, Callable
11
+ from contextvars import Context
11
12
  from dataclasses import dataclass, field
12
13
  from typing import Concatenate, ParamSpec, TypeVar, cast
13
14
  from uuid import uuid4
@@ -15,14 +16,12 @@ from uuid import uuid4
15
16
  from dishka import AsyncContainer
16
17
 
17
18
  from ..application_services import ApplicationServiceCatalog, ApplicationServiceContract
18
- from ..application_services.invocation import (
19
- _InvocationReservation,
20
- _InvocationRuntime,
21
- _ScopedApplicationServiceInvoker,
22
- )
19
+ from ..application_services.errors import ApplicationInvocationRejectedError
20
+ from ..application_services.invocation import _ScopedApplicationServiceInvoker
23
21
  from ..authorization import CurrentUser
24
22
  from ..invocation.contracts import _is_safe_correlation_id
25
23
  from ..invocation.function_runtime import _invoke_callable
24
+ from ..invocation.runtime import _InvocationReservation, _InvocationRuntime, _StoppingPermission
26
25
  from .errors import HostedServiceInvocationRejectedError
27
26
 
28
27
  _P = ParamSpec("_P")
@@ -33,6 +32,7 @@ _TContract = TypeVar("_TContract", bound=ApplicationServiceContract)
33
32
  @dataclass(slots=True)
34
33
  class _PendingInvocation:
35
34
  external_future: concurrent.futures.Future[object] | None
35
+ reservation: _InvocationReservation
36
36
  task: asyncio.Task[object] | None = None
37
37
 
38
38
 
@@ -48,6 +48,8 @@ class _HostedServiceBridge:
48
48
  "_loop",
49
49
  "_name",
50
50
  "_pending",
51
+ "_runtime",
52
+ "_stopping_owners",
51
53
  )
52
54
 
53
55
  def __init__(
@@ -58,6 +60,8 @@ class _HostedServiceBridge:
58
60
  container: AsyncContainer,
59
61
  catalog: ApplicationServiceCatalog,
60
62
  limit: int,
63
+ runtime: _InvocationRuntime,
64
+ stopping_owners: tuple[type[object], ...] = (),
61
65
  ) -> None:
62
66
  self._name = name
63
67
  self._loop = loop
@@ -67,6 +71,8 @@ class _HostedServiceBridge:
67
71
  self._lock = threading.RLock()
68
72
  self._pending: dict[object, _PendingInvocation] = {}
69
73
  self._accepting = False
74
+ self._runtime = runtime
75
+ self._stopping_owners = stopping_owners
70
76
 
71
77
  def open(self) -> None:
72
78
  with self._lock:
@@ -84,18 +90,25 @@ class _HostedServiceBridge:
84
90
  kwargs: dict[str, object],
85
91
  *,
86
92
  callable_entry: bool = False,
93
+ permission: _StoppingPermission | None = None,
87
94
  ) -> _R:
88
95
  operation = "invoke"
89
96
  self._require_application_loop(operation)
90
97
  self._validate_call(operation, correlation_id, method, callable_entry=callable_entry)
91
98
  token = object()
92
99
  with self._lock:
93
- self._admit(token, operation, external_future=None)
100
+ self._admit(token, operation, permission=permission)
94
101
  task = asyncio.create_task(
95
- self._invoke_pipeline(
96
- correlation_id, method, args, kwargs, callable_entry=callable_entry
102
+ self._invoke_accepted(
103
+ self._pending[token].reservation,
104
+ correlation_id,
105
+ method,
106
+ args,
107
+ kwargs,
108
+ callable_entry=callable_entry,
97
109
  ),
98
110
  name=f"python-ddd-framework-hosted-service-invoke:{self._name}",
111
+ context=Context(),
99
112
  )
100
113
  self._pending[token].task = task
101
114
  task.add_done_callback(functools.partial(self._release_task, token))
@@ -109,24 +122,26 @@ class _HostedServiceBridge:
109
122
  kwargs: dict[str, object],
110
123
  *,
111
124
  callable_entry: bool = False,
125
+ permission: _StoppingPermission | None = None,
112
126
  ) -> concurrent.futures.Future[_R]:
113
127
  operation = "submit"
114
128
  self._require_external_thread(operation)
115
129
  self._validate_call(operation, correlation_id, method, callable_entry=callable_entry)
116
130
  token = object()
117
131
  with self._lock:
118
- self._admit(token, operation, external_future=None)
132
+ self._admit(token, operation, permission=permission)
119
133
  invocation = self._invoke_submitted(
120
134
  token, correlation_id, method, args, kwargs, callable_entry=callable_entry
121
135
  )
122
136
  try:
123
- external = asyncio.run_coroutine_threadsafe(
137
+ external = Context().run(
138
+ asyncio.run_coroutine_threadsafe,
124
139
  invocation,
125
140
  self._loop,
126
141
  )
127
142
  except RuntimeError:
128
143
  invocation.close()
129
- self._pending.pop(token, None)
144
+ self._release_pending(token)
130
145
  raise HostedServiceInvocationRejectedError(
131
146
  service=self._name,
132
147
  operation=operation,
@@ -185,9 +200,11 @@ class _HostedServiceBridge:
185
200
  token: object,
186
201
  operation: str,
187
202
  *,
188
- external_future: concurrent.futures.Future[object] | None,
203
+ permission: _StoppingPermission | None,
189
204
  ) -> None:
190
- if not self._accepting:
205
+ if (permission is None and not self._accepting) or (
206
+ permission is not None and permission.owner not in self._stopping_owners
207
+ ):
191
208
  raise HostedServiceInvocationRejectedError(
192
209
  service=self._name,
193
210
  operation=operation,
@@ -199,7 +216,13 @@ class _HostedServiceBridge:
199
216
  operation=operation,
200
217
  error_type="CapacityExceeded",
201
218
  )
202
- self._pending[token] = _PendingInvocation(external_future=external_future)
219
+ try:
220
+ reservation = self._runtime.reserve(permission)
221
+ except ApplicationInvocationRejectedError:
222
+ raise HostedServiceInvocationRejectedError(
223
+ service=self._name, operation=operation, error_type="NotAccepting"
224
+ ) from None
225
+ self._pending[token] = _PendingInvocation(None, reservation)
203
226
 
204
227
  async def _invoke_submitted(
205
228
  self,
@@ -218,18 +241,28 @@ class _HostedServiceBridge:
218
241
  assert pending is not None
219
242
  pending.task = cast(asyncio.Task[object], task)
220
243
  try:
221
- return await self._invoke_pipeline(
222
- correlation_id, method, args, kwargs, callable_entry=callable_entry
244
+ return await self._invoke_accepted(
245
+ pending.reservation,
246
+ correlation_id,
247
+ method,
248
+ args,
249
+ kwargs,
250
+ callable_entry=callable_entry,
223
251
  )
224
252
  finally:
225
253
  # concurrent Future 可先进入 cancelled;只有 loop Task finalizer 完成才释放容量。
226
254
  with self._lock:
227
- self._pending.pop(token, None)
255
+ self._release_pending(token)
228
256
 
229
257
  def _release_task(self, token: object, task: asyncio.Task[object]) -> None:
230
258
  del task
231
259
  with self._lock:
232
- self._pending.pop(token, None)
260
+ self._release_pending(token)
261
+
262
+ def _release_pending(self, token: object) -> None:
263
+ pending = self._pending.pop(token, None)
264
+ if pending is not None and not pending.reservation.executing:
265
+ self._runtime.release_reservation(pending.reservation)
233
266
 
234
267
  def _observe_external_completion(
235
268
  self,
@@ -269,7 +302,22 @@ class _HostedServiceBridge:
269
302
  and pending.task is None
270
303
  and external.cancelled()
271
304
  ):
272
- self._pending.pop(token, None)
305
+ self._release_pending(token)
306
+
307
+ async def _invoke_accepted(
308
+ self,
309
+ reservation: _InvocationReservation,
310
+ correlation_id: str,
311
+ method: Callable[..., Awaitable[object]],
312
+ args: tuple[object, ...],
313
+ kwargs: dict[str, object],
314
+ *,
315
+ callable_entry: bool,
316
+ ) -> object:
317
+ async with self._runtime.reserved_lease(reservation):
318
+ return await self._invoke_pipeline(
319
+ correlation_id, method, args, kwargs, callable_entry=callable_entry
320
+ )
273
321
 
274
322
  async def _invoke_pipeline(
275
323
  self,
@@ -370,6 +418,7 @@ class HostedServiceContext:
370
418
 
371
419
  name: str
372
420
  _bridge: _HostedServiceBridge = field(repr=False, compare=False)
421
+ _permission: _StoppingPermission | None = field(default=None, repr=False, compare=False)
373
422
 
374
423
  async def call(
375
424
  self,
@@ -378,7 +427,9 @@ class HostedServiceContext:
378
427
  *args: object,
379
428
  **kwargs: object,
380
429
  ) -> _R:
381
- return await self._bridge.invoke(str(uuid4()), function, args, kwargs, callable_entry=True)
430
+ return await self._bridge.invoke(
431
+ str(uuid4()), function, args, kwargs, callable_entry=True, permission=self._permission
432
+ )
382
433
 
383
434
  def submit_call(
384
435
  self,
@@ -388,7 +439,9 @@ class HostedServiceContext:
388
439
  **kwargs: object,
389
440
  ) -> concurrent.futures.Future[_R]:
390
441
  """外部线程继续经过同一 admission/drain bridge,不直接接触异步容器。"""
391
- return self._bridge.submit(str(uuid4()), function, args, kwargs, callable_entry=True)
442
+ return self._bridge.submit(
443
+ str(uuid4()), function, args, kwargs, callable_entry=True, permission=self._permission
444
+ )
392
445
 
393
446
  async def invoke(
394
447
  self,
@@ -412,6 +465,7 @@ class HostedServiceContext:
412
465
  method,
413
466
  cast(tuple[object, ...], args),
414
467
  cast(dict[str, object], kwargs),
468
+ permission=self._permission,
415
469
  )
416
470
 
417
471
  def submit(
@@ -436,4 +490,5 @@ class HostedServiceContext:
436
490
  method,
437
491
  cast(tuple[object, ...], args),
438
492
  cast(dict[str, object], kwargs),
493
+ permission=self._permission,
439
494
  )
@@ -27,6 +27,10 @@ class HostedService(ABC):
27
27
  async def start(self, context: HostedServiceContext) -> None:
28
28
  raise NotImplementedError
29
29
 
30
+ async def stopping(self, context: HostedServiceContext, reason: ShutdownReason) -> None:
31
+ """在依赖仍存活时发起业务收尾并等待回调;context 只在本钩子内有效。"""
32
+ return None
33
+
30
34
  @abstractmethod
31
35
  async def stop(self, reason: ShutdownReason) -> None:
32
36
  raise NotImplementedError
@@ -52,7 +56,7 @@ def _validate_service_type(candidate: type[HostedService]) -> None:
52
56
  )
53
57
  if inspect.isabstract(candidate):
54
58
  raise HostedServiceDefinitionError(service=name, reason="service type must be concrete")
55
- for operation in ("start", "stop"):
59
+ for operation in ("start", "stopping", "stop"):
56
60
  if not inspect.iscoroutinefunction(getattr(candidate, operation, None)):
57
61
  raise HostedServiceDefinitionError(
58
62
  service=name, reason=f"{operation} must use async def"
@@ -10,6 +10,7 @@ from typing import cast
10
10
  from dishka import AsyncContainer
11
11
 
12
12
  from ..application_services import ApplicationServiceCatalog
13
+ from ..invocation.runtime import _InvocationRuntime
13
14
  from ..lifecycle import ApplicationState, RuntimeParticipant, ShutdownReason
14
15
  from .bridge import HostedServiceContext, _HostedServiceBridge
15
16
  from .catalog import HostedServiceCatalog, HostedServiceRegistration
@@ -25,6 +26,8 @@ class _HostedServiceEntry:
25
26
  service: HostedService
26
27
  context: HostedServiceContext
27
28
  bridge: _HostedServiceBridge
29
+ started: bool = False
30
+ failed: bool = False
28
31
 
29
32
 
30
33
  class _HostedServiceCoordinator:
@@ -97,7 +100,10 @@ class _HostedServiceCoordinator:
97
100
  prepare=prepare,
98
101
  start=start,
99
102
  stop=self.stop,
103
+ quiesce=self.close_admission,
104
+ stopping=self.stopping,
100
105
  close_admission=self.close_admission,
106
+ drain=self.drain,
101
107
  ready=lambda: self.readiness().ready,
102
108
  )
103
109
 
@@ -126,6 +132,7 @@ class _HostedServiceCoordinator:
126
132
  if container is None or application_service_catalog is None:
127
133
  raise RuntimeError("Hosted Service coordinator container is unavailable")
128
134
  self._started = True
135
+ runtime = await container.get(_InvocationRuntime)
129
136
  for registration in self._registrations:
130
137
  self._state_owner.set_status(registration.name, HostedServiceStatus.STARTING)
131
138
  try:
@@ -150,17 +157,55 @@ class _HostedServiceCoordinator:
150
157
  container=container,
151
158
  catalog=application_service_catalog,
152
159
  limit=self._options.max_pending_invocations,
160
+ runtime=runtime,
161
+ stopping_owners=(registration.service_type,),
153
162
  )
154
163
  context = HostedServiceContext(registration.name, bridge)
155
164
  entry = _HostedServiceEntry(registration, resolved, context, bridge)
156
165
  self._entries.append(entry)
157
166
  bridge.open()
158
167
  await self._start_entry(entry)
168
+ entry.started = True
159
169
 
160
170
  def close_admission(self) -> None:
161
171
  for entry in self._entries:
162
172
  entry.bridge.close()
163
173
 
174
+ async def stopping(self, reason: ShutdownReason) -> tuple[BaseException, ...]:
175
+ failures: list[BaseException] = []
176
+ for entry in reversed(self._entries):
177
+ self._state_owner.set_status(entry.registration.name, HostedServiceStatus.STOPPING)
178
+ if not entry.started:
179
+ continue
180
+ assert self._container is not None
181
+ runtime = await self._container.get(_InvocationRuntime)
182
+ permission = runtime.grant_stopping(entry.registration.service_type)
183
+ context = HostedServiceContext(entry.registration.name, entry.bridge, permission)
184
+ try:
185
+ failure = await self._stop_entry(entry, reason, context=context)
186
+ except BaseException as error:
187
+ failures.append(error)
188
+ entry.failed = True
189
+ else:
190
+ if failure is not None:
191
+ failures.append(failure)
192
+ entry.failed = True
193
+ finally:
194
+ runtime.revoke_stopping(permission)
195
+ return tuple(failures)
196
+
197
+ async def drain(self) -> None:
198
+ outcomes = await asyncio.gather(
199
+ *(
200
+ entry.bridge.drain(self._options.shutdown_timeout.total_seconds())
201
+ for entry in self._entries
202
+ ),
203
+ return_exceptions=True,
204
+ )
205
+ failures = tuple(item for item in outcomes if isinstance(item, BaseException))
206
+ if failures:
207
+ raise BaseExceptionGroup("Hosted Service invocation drain failed", failures)
208
+
164
209
  async def stop(
165
210
  self,
166
211
  reason: ShutdownReason,
@@ -176,19 +221,14 @@ class _HostedServiceCoordinator:
176
221
  if failure is not None:
177
222
  failures.append(failure)
178
223
 
179
- if self._entries:
180
- await asyncio.gather(
181
- *(
182
- entry.bridge.drain(self._options.shutdown_timeout.total_seconds())
183
- for entry in self._entries
184
- )
185
- )
186
224
  failed_names = {failure.service for failure in failures}
187
225
  for entry in self._entries:
188
226
  name = entry.registration.name
189
227
  self._state_owner.set_status(
190
228
  name,
191
- HostedServiceStatus.FAILED if name in failed_names else HostedServiceStatus.STOPPED,
229
+ HostedServiceStatus.FAILED
230
+ if name in failed_names or entry.failed
231
+ else HostedServiceStatus.STOPPED,
192
232
  )
193
233
  self._started = False
194
234
  return tuple(failures)
@@ -253,18 +293,29 @@ class _HostedServiceCoordinator:
253
293
  self,
254
294
  entry: _HostedServiceEntry,
255
295
  reason: ShutdownReason,
296
+ *,
297
+ context: HostedServiceContext | None = None,
256
298
  ) -> HostedServiceLifecycleError | None:
257
299
  name = entry.registration.name
258
- callback = cast(
259
- Callable[[ShutdownReason], Awaitable[object]],
260
- entry.service.stop,
261
- )
300
+ operation = "stopping" if context is not None else "stop"
262
301
 
263
302
  async def invoke_stop() -> object:
264
- return await callback(reason)
303
+ if context is None:
304
+ callback = cast(Callable[[ShutdownReason], Awaitable[object]], entry.service.stop)
305
+ return await callback(reason)
306
+ stopping_callback = cast(
307
+ Callable[[HostedServiceContext, ShutdownReason], Awaitable[object]],
308
+ entry.service.stopping,
309
+ )
310
+ try:
311
+ return await stopping_callback(context, reason)
312
+ finally:
313
+ # 返回和失败都在用户钩子的 task 内撤权,不留下一轮调度的准入窗口。
314
+ assert context._permission is not None
315
+ context._permission.runtime.revoke_stopping(context._permission)
265
316
 
266
317
  stop_task = asyncio.create_task(
267
- invoke_stop(), name=f"python-ddd-framework-hosted-service-stop:{name}"
318
+ invoke_stop(), name=f"python-ddd-framework-hosted-service-{operation}:{name}"
268
319
  )
269
320
  done, _ = await asyncio.wait(
270
321
  (stop_task,), timeout=self._options.shutdown_timeout.total_seconds()
@@ -272,25 +323,28 @@ class _HostedServiceCoordinator:
272
323
  timed_out = not done
273
324
  if timed_out:
274
325
  # 取消只是一项请求。实现可能在 finally 中异步释放 SDK 资源;完成前不能释放容器。
326
+ if context is not None:
327
+ assert context._permission is not None
328
+ context._permission.runtime.revoke_stopping(context._permission)
275
329
  stop_task.cancel()
276
330
  try:
277
331
  result = await stop_task
278
332
  except BaseException as original_error:
279
333
  return HostedServiceLifecycleError(
280
334
  service=name,
281
- operation="stop",
335
+ operation=operation,
282
336
  error_type="TimeoutError"
283
337
  if timed_out and isinstance(original_error, asyncio.CancelledError)
284
338
  else type(original_error).__name__,
285
339
  )
286
340
  if timed_out:
287
341
  return HostedServiceLifecycleError(
288
- service=name, operation="stop", error_type="TimeoutError"
342
+ service=name, operation=operation, error_type="TimeoutError"
289
343
  )
290
344
  if result is not None:
291
345
  return HostedServiceLifecycleError(
292
346
  service=name,
293
- operation="stop",
347
+ operation=operation,
294
348
  error_type="InvalidReturnValue",
295
349
  )
296
350
  return None
@@ -3,26 +3,17 @@ from .application import (
3
3
  )
4
4
  from .contracts import (
5
5
  IDENTITY_ROLES_MANAGE,
6
- IDENTITY_USER_EXTENSION,
7
6
  IDENTITY_USERS_MANAGE,
8
7
  AuthenticationApplicationService,
8
+ IdentityManagementApplicationService,
9
+ )
10
+ from .dtos import (
9
11
  ChangePasswordCommand,
10
12
  CreateRoleCommand,
11
13
  CreateUserCommand,
12
14
  CurrentUserView,
13
- IdentityAccessStore,
14
15
  IdentityConcurrencyCommand,
15
- IdentityLoginStore,
16
- IdentityManagementApplicationService,
17
- IdentityOptions,
18
- IdentityRepository,
19
- IdentityRole,
20
- IdentityUser,
21
16
  LoginCommand,
22
- RefreshRotationResult,
23
- RefreshRotationStatus,
24
- RefreshSession,
25
- RefreshSessionStore,
26
17
  RefreshTokenCommand,
27
18
  RevokeTokenCommand,
28
19
  RolePermissionsView,
@@ -43,10 +34,26 @@ from .errors import (
43
34
  IdentityResourceNotFoundError,
44
35
  InvalidCredentialsError,
45
36
  )
37
+ from .extensions import IDENTITY_USER_EXTENSION
46
38
  from .management import DefaultIdentityManagementApplicationService
39
+ from .models import (
40
+ IdentityRole,
41
+ IdentityUser,
42
+ RefreshRotationResult,
43
+ RefreshRotationStatus,
44
+ RefreshSession,
45
+ )
47
46
  from .module import IdentityModule
47
+ from .options import IdentityOptions
48
48
  from .passwords import PasswordHasher
49
49
  from .services import IdentityAuthenticator
50
+ from .stores import (
51
+ IdentityAccessStore,
52
+ IdentityCredentialStore,
53
+ IdentityLoginStore,
54
+ IdentityManagementStore,
55
+ RefreshSessionStore,
56
+ )
50
57
  from .tokens import JwtTokenService
51
58
 
52
59
  __all__ = (
@@ -64,12 +71,13 @@ __all__ = (
64
71
  "IdentityAuthenticator",
65
72
  "IdentityConcurrencyCommand",
66
73
  "IdentityConflictError",
74
+ "IdentityCredentialStore",
67
75
  "IdentityDefinitionError",
68
76
  "IdentityLoginStore",
69
77
  "IdentityManagementApplicationService",
78
+ "IdentityManagementStore",
70
79
  "IdentityModule",
71
80
  "IdentityOptions",
72
- "IdentityRepository",
73
81
  "IdentityResourceNotFoundError",
74
82
  "IdentityRole",
75
83
  "IdentityUser",
@@ -1,4 +1,4 @@
1
- """Identity Application Services:认证、refresh family 与管理员业务编排。"""
1
+ """Identity 登录、令牌与当前用户认证流程。"""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,34 +8,27 @@ from uuid import UUID, uuid4
8
8
  from ..application_services import ApplicationService, application_service
9
9
  from ..authorization import (
10
10
  CurrentUser,
11
- PermissionCatalog,
12
11
  allow_anonymous,
13
12
  authorize,
14
13
  )
15
14
  from ..options import Options
16
- from ..seeding.contracts import DataSeedContributor
17
- from .contracts import (
18
- AuthenticationApplicationService,
15
+ from .contracts import AuthenticationApplicationService
16
+ from .domain_rules import _normalize_name
17
+ from .dtos import (
19
18
  ChangePasswordCommand,
20
19
  CurrentUserView,
21
- IdentityLoginStore,
22
- IdentityOptions,
23
- IdentityRepository,
24
- IdentityRole,
25
- IdentityUser,
26
20
  LoginCommand,
27
- RefreshRotationStatus,
28
- RefreshSession,
29
- RefreshSessionStore,
30
21
  RefreshTokenCommand,
31
22
  RevokeTokenCommand,
32
23
  TokenPair,
33
- _normalize_name,
34
24
  )
35
25
  from .errors import (
36
26
  InvalidCredentialsError,
37
27
  )
28
+ from .models import RefreshRotationStatus, RefreshSession
29
+ from .options import IdentityOptions
38
30
  from .passwords import PasswordHasher
31
+ from .stores import IdentityCredentialStore, IdentityLoginStore, RefreshSessionStore
39
32
  from .tokens import (
40
33
  BEARER_TOKEN_TYPE,
41
34
  JwtTokenService,
@@ -54,7 +47,7 @@ class DefaultAuthenticationApplicationService(ApplicationService):
54
47
  passwords: PasswordHasher,
55
48
  tokens: JwtTokenService,
56
49
  current_user: CurrentUser,
57
- repository: IdentityRepository,
50
+ credentials: IdentityCredentialStore,
58
51
  options: Options[IdentityOptions],
59
52
  ) -> None:
60
53
  self._login_store = login_store
@@ -62,7 +55,7 @@ class DefaultAuthenticationApplicationService(ApplicationService):
62
55
  self._passwords = passwords
63
56
  self._tokens = tokens
64
57
  self._current_user = current_user
65
- self._repository = repository
58
+ self._credentials = credentials
66
59
  self._options = options.value
67
60
 
68
61
  @allow_anonymous
@@ -137,7 +130,7 @@ class DefaultAuthenticationApplicationService(ApplicationService):
137
130
  session_id=self._current_user.session_id,
138
131
  username=self._current_user.username,
139
132
  concurrency_version=(
140
- await self._repository.get_user(self._current_user.user_id)
133
+ await self._credentials.get_user(self._current_user.user_id)
141
134
  ).concurrency_version,
142
135
  )
143
136
 
@@ -145,7 +138,7 @@ class DefaultAuthenticationApplicationService(ApplicationService):
145
138
  user_id = self._current_user.user_id
146
139
  if user_id is None:
147
140
  raise InvalidCredentialsError
148
- user = await self._repository.get_user(user_id)
141
+ user = await self._credentials.get_user(user_id)
149
142
  verified, _ = await self._passwords.verify(
150
143
  command.old_password.get_secret_value(), user.password_hash
151
144
  )
@@ -153,7 +146,7 @@ class DefaultAuthenticationApplicationService(ApplicationService):
153
146
  raise InvalidCredentialsError
154
147
  password_hash = await self._passwords.hash(command.new_password.get_secret_value())
155
148
  # Hash 在线程计算;写入时重新核对版本及旧 hash,拒绝计算期间的凭据竞争。
156
- await self._repository.set_password(
149
+ await self._credentials.set_password(
157
150
  user_id,
158
151
  password_hash,
159
152
  command.concurrency_version,
@@ -175,53 +168,6 @@ class DefaultAuthenticationApplicationService(ApplicationService):
175
168
  )
176
169
 
177
170
 
178
- class IdentityAdminSeedContributor(DataSeedContributor):
179
- def __init__(
180
- self,
181
- repository: IdentityRepository,
182
- passwords: PasswordHasher,
183
- permissions: PermissionCatalog,
184
- options: Options[IdentityOptions],
185
- ) -> None:
186
- self._repository = repository
187
- self._passwords = passwords
188
- self._permissions = permissions
189
- self._options = options.value
190
-
191
- async def seed(self) -> None:
192
- username = self._options.seed_admin_username
193
- password = self._options.seed_admin_password
194
- if username is None or password is None:
195
- return
196
- normalized_username = _normalize_name(username, role="seed administrator username")
197
- user = await self._repository.find_user_by_normalized_username(normalized_username)
198
- if user is None:
199
- user = IdentityUser(
200
- uuid4(),
201
- username.strip(),
202
- normalized_username,
203
- await self._passwords.hash(password.get_secret_value()),
204
- True,
205
- 0,
206
- None,
207
- 0,
208
- )
209
- await self._repository.create_user(user)
210
- normalized_role = "administrator"
211
- role = await self._repository.find_role_by_normalized_name(normalized_role)
212
- if role is None:
213
- role = IdentityRole(uuid4(), "Administrator", normalized_role, 0)
214
- await self._repository.create_role(role)
215
- await self._repository.set_role_permissions(
216
- role.role_id,
217
- tuple(item.name.value for item in self._permissions.definitions),
218
- role.concurrency_version,
219
- )
220
- await self._repository.set_user_roles(
221
- user.user_id, (role.role_id,), user.concurrency_version
222
- )
223
-
224
-
225
171
  def _new_session(user_id: UUID, refresh_token: str, *, now: datetime) -> RefreshSession:
226
172
  family_id = uuid4()
227
173
  return RefreshSession(