python-ddd-framework 0.6.0__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 (90) hide show
  1. python_ddd_framework/authorization/checking.py +35 -0
  2. python_ddd_framework/developer_kit/generation.py +0 -9
  3. python_ddd_framework/developer_kit/templates/module/cookiecutter.json +1 -1
  4. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +29 -77
  5. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +5 -72
  6. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +1 -3
  7. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +2 -3
  8. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +8 -62
  9. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +1 -2
  10. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +12 -8
  11. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +22 -10
  12. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/backend/src/host/main.py.jinja +4 -1
  13. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +40 -6
  14. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +177 -30
  15. python_ddd_framework/fastapi/__init__.py +2 -0
  16. python_ddd_framework/fastapi/access.py +127 -0
  17. python_ddd_framework/fastapi/access_options.py +13 -0
  18. python_ddd_framework/fastapi/action.py +6 -0
  19. python_ddd_framework/fastapi/manual_action.py +33 -2
  20. python_ddd_framework/fastapi/module.py +2 -0
  21. python_ddd_framework/fastapi/realtime/authentication.py +21 -10
  22. python_ddd_framework/fastapi/realtime/options.py +0 -1
  23. python_ddd_framework/fastapi/realtime/runtime.py +41 -26
  24. python_ddd_framework/fastapi/request_context.py +10 -2
  25. python_ddd_framework/fastapi/routing.py +12 -5
  26. python_ddd_framework/fastapi/server.py +9 -0
  27. python_ddd_framework/fastapi/service_endpoints.py +5 -0
  28. python_ddd_framework/fastapi/streaming.py +95 -0
  29. python_ddd_framework/fastapi/transfer.py +138 -3
  30. python_ddd_framework/invocation/dispatcher.py +2 -24
  31. python_ddd_framework/invocation/scopes.py +5 -3
  32. python_ddd_framework/observability/formatting.py +1 -0
  33. python_ddd_framework/unit_of_work/manager.py +11 -2
  34. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/METADATA +29 -8
  35. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/RECORD +39 -86
  36. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/WHEEL +1 -1
  37. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/__init__.py.jinja +0 -1
  38. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/handler.py.jinja +0 -45
  39. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/payload.py.jinja +0 -8
  40. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/__init__.py.jinja +0 -1
  41. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/handler.py.jinja +0 -35
  42. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/payload.py.jinja +0 -5
  43. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/schedule.py.jinja +0 -15
  44. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_maintenance_worker.py.jinja +0 -28
  45. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_statistics_worker.py.jinja +0 -26
  46. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/order_cache.py.jinja +0 -9
  47. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/statistics_cache.py.jinja +0 -9
  48. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/order_changed_handler.py.jinja +0 -23
  49. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +0 -71
  50. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +0 -16
  51. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/order_reporting_service.py.jinja +0 -16
  52. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/order_timing_interceptor.py.jinja +0 -17
  53. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options/order_options.py.jinja +0 -7
  54. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_approval_service.py.jinja +0 -65
  55. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_management_service.py.jinja +0 -35
  56. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_query_service.py.jinja +0 -41
  57. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/approval_setting_observer.py.jinja +0 -17
  58. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/approve_order.py.jinja +0 -6
  59. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/create_order.py.jinja +0 -7
  60. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/order_reporting_service.py.jinja +0 -7
  61. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_approval_service.py.jinja +0 -13
  62. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_management_service.py.jinja +0 -10
  63. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_query_service.py.jinja +0 -12
  64. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_statistics_snapshot.py.jinja +0 -9
  65. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_view.py.jinja +0 -12
  66. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/entities/order.py.jinja +0 -48
  67. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/order_changed.py.jinja +0 -13
  68. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/order_repository.py.jinja +0 -17
  69. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/order_seed_contributor.py.jinja +0 -22
  70. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/order_approval_service.py.jinja +0 -23
  71. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings/approval_settings.py.jinja +0 -17
  72. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/order_title.py.jinja +0 -10
  73. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/order_constants.py.jinja +0 -3
  74. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/order_status.py.jinja +0 -6
  75. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/order_errors.py.jinja +0 -11
  76. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_messages.py.jinja +0 -26
  77. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_observation.py.jinja +0 -13
  78. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permission_provider.py.jinja +0 -19
  79. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permissions.py.jinja +0 -7
  80. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/money.py.jinja +0 -18
  81. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/export_filter.py.jinja +0 -15
  82. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/refresh_orders.py.jinja +0 -5
  83. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/routers/order_files.py.jinja +0 -76
  84. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/order_socket.py.jinja +0 -44
  85. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/order_model.py.jinja +0 -29
  86. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +0 -81
  87. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +0 -19
  88. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/entry_points.txt +0 -0
  89. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/LICENSE +0 -0
  90. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
@@ -0,0 +1,95 @@
1
+ """生成器端点的原生 Depends 准备与发送交接;协议编码仍由 FastAPI 完成。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable
7
+ from functools import update_wrapper
8
+ from typing import Annotated, Any
9
+
10
+ from dishka.integrations.fastapi import inject
11
+ from fastapi import Depends
12
+ from starlette.concurrency import iterate_in_threadpool
13
+ from starlette.responses import Response
14
+
15
+ from ..unit_of_work import UnitOfWorkManager
16
+ from .action import _HttpActionOwner
17
+ from .transfer import _manage_source
18
+
19
+
20
+ class _PreparedStream:
21
+ """保存准备阶段移交的源;原生 producer 取得发送许可后才允许读取正文。"""
22
+
23
+ def __init__(self, owner: _HttpActionOwner, result: object) -> None:
24
+ self._owner = owner
25
+ self._result = result
26
+ _manage_source(owner.request, result)
27
+
28
+ async def iterate(self) -> AsyncIterator[object]:
29
+ owner = self._owner
30
+ # SSE 在函数退出栈完成前启动原生 producer;不预读首项,也不携带准备期事务。
31
+ await owner.prepared.wait()
32
+ if isinstance(self._result, Response):
33
+ return
34
+ await owner.sending.wait()
35
+ manager = await owner.container.get(UnitOfWorkManager)
36
+ initial_user, correlation = owner.request.state.python_ddd_framework_identity
37
+ access = getattr(owner.request.state, "python_ddd_framework_access", None)
38
+ if isinstance(self._result, AsyncIterable):
39
+ iterator = aiter(self._result)
40
+ elif isinstance(self._result, Iterable):
41
+ iterator = iterate_in_threadpool(self._result)
42
+ else:
43
+ raise TypeError("a streaming action must produce an iterable or Response")
44
+ while True:
45
+ # ContextVar token 不跨 yield;每次读取单独取得可信发送许可,
46
+ # 避免原生包装器延迟 finalization 时在不同 task 恢复准备上下文。
47
+ with (
48
+ manager._detached_work(),
49
+ owner.runtime.detached_execution(owner.container),
50
+ ):
51
+ # SSE producer 的复验也可能执行短 UoW,必须先交接该 task 的调用许可。
52
+ with owner.runtime.trusted_http_task(
53
+ owner.container, initial_user, correlation, owner.request.method
54
+ ):
55
+ user = await access.check() if access is not None else initial_user
56
+ # 检查可能刷新身份;正文的短调用使用检查后的快照,不能沿用准备期身份。
57
+ with owner.runtime.trusted_http_task(
58
+ owner.container, user, correlation, owner.request.method
59
+ ):
60
+ try:
61
+ item = await anext(iterator)
62
+ except StopAsyncIteration:
63
+ return
64
+ yield item
65
+
66
+
67
+ def _stream_endpoint(
68
+ prepare: Callable[..., Any], signature: inspect.Signature
69
+ ) -> Callable[..., Any]:
70
+ """准备交给原生 Depends,外层仍暴露生成器供 FastAPI 选择编码与传输路径。"""
71
+ # Depends 必须看到真实 coroutine,不能沿 __wrapped__ 把准备函数误当 yield dependency。
72
+ del prepare.__wrapped__ # type: ignore[attr-defined]
73
+ prepare.__signature__ = inspect.signature(prepare).replace( # type: ignore[attr-defined]
74
+ return_annotation=_PreparedStream
75
+ )
76
+ prepare.__annotations__["return"] = _PreparedStream
77
+ dependency = inject(prepare)
78
+
79
+ async def stream(_prepared: _PreparedStream) -> AsyncIterator[object]:
80
+ async for item in _prepared.iterate():
81
+ yield item
82
+
83
+ update_wrapper(stream, prepare)
84
+ delattr(stream, "__wrapped__")
85
+ parameter = inspect.Parameter(
86
+ "_prepared",
87
+ inspect.Parameter.KEYWORD_ONLY,
88
+ annotation=Annotated[_PreparedStream, Depends(dependency)],
89
+ )
90
+ stream.__signature__ = signature.replace(parameters=(parameter,)) # type: ignore[attr-defined]
91
+ stream.__annotations__ = {
92
+ "_prepared": parameter.annotation,
93
+ "return": signature.return_annotation,
94
+ }
95
+ return stream
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import inspect
7
- from collections.abc import Awaitable, Callable
7
+ from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Iterable
8
8
  from contextlib import AsyncExitStack
9
9
 
10
10
  from anyio import CancelScope
@@ -12,9 +12,10 @@ from starlette.concurrency import iterate_in_threadpool, run_in_threadpool
12
12
  from starlette.datastructures import FormData, UploadFile
13
13
  from starlette.requests import ClientDisconnect, Request
14
14
  from starlette.responses import StreamingResponse
15
- from starlette.types import Send
15
+ from starlette.types import Message, Send
16
16
 
17
17
  from ..invocation.runtime import _InvocationRuntime
18
+ from .access import _AccessRefresh
18
19
 
19
20
 
20
21
  async def _close_form(form: FormData) -> None:
@@ -28,6 +29,7 @@ async def _close_form(form: FormData) -> None:
28
29
 
29
30
  def _manage_stream(request: Request, response: object) -> None:
30
31
  if not isinstance(response, StreamingResponse):
32
+ _manage_source(request, response)
31
33
  return
32
34
  if isinstance(getattr(response.stream_response, "__self__", None), _StreamLifetime):
33
35
  return
@@ -51,10 +53,84 @@ def _manage_stream(request: Request, response: object) -> None:
51
53
  response.body_iterator = iterator
52
54
  close = getattr(iterator, "aclose", None)
53
55
  lifetime = _StreamLifetime(request, response.stream_response, close)
56
+ if lifetime._access is not None:
57
+ response.body_iterator = lifetime.read(response.body_iterator)
54
58
  response.stream_response = lifetime.send # type: ignore[method-assign]
55
59
  request.scope["fastapi_middleware_astack"].push_async_callback(lifetime.close)
56
60
 
57
61
 
62
+ def _manage_source(request: Request, source: object) -> None:
63
+ if not isinstance(source, (AsyncIterable, Iterable)):
64
+ return
65
+ sources: dict[int, _SourceLifetime] | None = getattr(
66
+ request.state, "python_ddd_framework_stream_sources", None
67
+ )
68
+ if sources is None:
69
+ sources = {}
70
+ request.state.python_ddd_framework_stream_sources = sources
71
+ if id(source) in sources:
72
+ return
73
+ close = getattr(source, "aclose", None)
74
+ sync_close = getattr(source, "close", None)
75
+ if close is None and sync_close is None:
76
+ return
77
+ lifetime = _SourceLifetime(request, source, close, sync_close)
78
+ sources[id(source)] = lifetime
79
+ # 源在准备依赖执行后、原生 SSE task group 建立前登记:退出时先 join producer,
80
+ # 再关源,最后才释放先登记的订阅依赖。不能把源留到订阅退出后的 middleware 栈。
81
+ request.scope["fastapi_inner_astack"].push_async_callback(lifetime.close)
82
+
83
+
84
+ class _SourceLifetime:
85
+ """在迭代退出后强持有唯一关闭任务,等待源清理真正完成。"""
86
+
87
+ def __init__(
88
+ self,
89
+ request: Request,
90
+ source: object,
91
+ close: Callable[[], Awaitable[None]] | None,
92
+ sync_close: Callable[[], None] | None,
93
+ ) -> None:
94
+ self._request = request
95
+ self.source = source
96
+ self._close = close
97
+ self._sync_close = sync_close
98
+ self._task: asyncio.Task[None] | None = None
99
+
100
+ async def _run(self) -> None:
101
+ container = self._request.state.dishka_container
102
+ runtime = await container.get(_InvocationRuntime)
103
+ user, correlation = self._request.state.python_ddd_framework_identity
104
+ with (
105
+ CancelScope(shield=True),
106
+ runtime.trusted_http_task(container, user, correlation, self._request.method),
107
+ ):
108
+ if self._close is not None:
109
+ await self._close()
110
+ elif self._sync_close is not None:
111
+ await run_in_threadpool(self._sync_close)
112
+
113
+ async def close(self) -> None:
114
+ if self._task is None:
115
+ self._task = asyncio.create_task(self._run())
116
+ # 重复取消只记下待传播的异常,不打断同一个清理任务或提前释放 REQUEST。
117
+ primary: asyncio.CancelledError | None = None
118
+ with CancelScope(shield=True):
119
+ while not self._task.done():
120
+ try:
121
+ await asyncio.wait((self._task,))
122
+ except asyncio.CancelledError as error:
123
+ primary = primary or error
124
+ try:
125
+ self._task.result()
126
+ except BaseException as error:
127
+ if primary is not None:
128
+ raise error from primary
129
+ raise
130
+ if primary is not None:
131
+ raise primary
132
+
133
+
58
134
  class _StreamLifetime:
59
135
  """producer 串行拥有迭代与关闭;请求监督它结束后才允许 REQUEST/stop 退出。"""
60
136
 
@@ -67,6 +143,12 @@ class _StreamLifetime:
67
143
  self._request = request
68
144
  self._send = send
69
145
  self._close = close
146
+ self._access: _AccessRefresh | None = getattr(
147
+ request.state, "python_ddd_framework_access", None
148
+ )
149
+ self._ready: asyncio.Event | None = getattr(
150
+ request.state, "python_ddd_framework_stream_ready", None
151
+ )
70
152
 
71
153
  async def close(self) -> None:
72
154
  # 先取走回调,避免 producer finally 与请求退出栈重复关闭同一资源。
@@ -76,6 +158,24 @@ class _StreamLifetime:
76
158
  with CancelScope(shield=True):
77
159
  await close()
78
160
 
161
+ async def read(
162
+ self, source: AsyncIterable[str | bytes | memoryview]
163
+ ) -> AsyncIterator[str | bytes | memoryview]:
164
+ assert self._access is not None
165
+ container = self._request.state.dishka_container
166
+ runtime = await container.get(_InvocationRuntime)
167
+ _, correlation = self._request.state.python_ddd_framework_identity
168
+ iterator = aiter(source)
169
+ while True:
170
+ user = await self._access.check()
171
+ # 原生 Response 的正文短调用也使用最新快照;DI 已注入的不可变值不原地修改。
172
+ with runtime.trusted_http_task(container, user, correlation, self._request.method):
173
+ try:
174
+ item = await anext(iterator)
175
+ except StopAsyncIteration:
176
+ return
177
+ yield item
178
+
79
179
  async def send(self, send: Send) -> None:
80
180
  # scope 可在 producer 启动前取消,但只能由 producer 自己进入/退出。
81
181
  cancel_scope = CancelScope()
@@ -111,6 +211,7 @@ class _StreamLifetime:
111
211
  raise
112
212
 
113
213
  async def _run(self, send: Send, cancel_scope: CancelScope) -> None:
214
+ """在所属 task 的可信许可内完成首检、发送和关闭,并等待后台复验退出。"""
114
215
  container = self._request.state.dishka_container
115
216
  runtime = await container.get(_InvocationRuntime)
116
217
  user, correlation = self._request.state.python_ddd_framework_identity
@@ -120,9 +221,43 @@ class _StreamLifetime:
120
221
  CancelScope(shield=True),
121
222
  runtime.trusted_http_task(container, user, correlation, self._request.method),
122
223
  ):
224
+ watchdog: asyncio.Task[None] | None = None
225
+
226
+ async def protected_send(message: Message) -> None:
227
+ if self._access is not None:
228
+ await self._access.check()
229
+ await send(message)
230
+ if message["type"] == "http.response.start" and self._ready is not None:
231
+ self._ready.set()
232
+
123
233
  try:
124
234
  with cancel_scope:
125
- await self._send(send)
235
+ # ASGI <2.4 的 Starlette 会在子 task 调用 send;首次复验也必须在
236
+ # 当前受监督入口取得许可后执行,且仍先于实际 response.start。
237
+ if self._access is not None:
238
+ await self._access.check(force=True)
239
+ watchdog = asyncio.create_task(self._watch_access(cancel_scope))
240
+ await self._send(protected_send)
241
+ if self._access is not None and self._access.failure is not None:
242
+ raise self._access.failure
126
243
  finally:
244
+ if watchdog is not None:
245
+ watchdog.cancel()
246
+ await asyncio.gather(watchdog, return_exceptions=True)
127
247
  # 发送/next 已退出才能串行关闭;生成器内部 finally 的 await 由其自己 shield。
128
248
  await self.close()
249
+
250
+ async def _watch_access(self, cancel_scope: CancelScope) -> None:
251
+ assert self._access is not None
252
+ container = self._request.state.dishka_container
253
+ runtime = await container.get(_InvocationRuntime)
254
+ user, correlation = self._request.state.python_ddd_framework_identity
255
+ try:
256
+ # create_task 只复制上下文;复验的短调用仍须由传输 owner 明确交接许可。
257
+ with runtime.trusted_http_task(container, user, correlation, self._request.method):
258
+ while True:
259
+ await asyncio.sleep(self._access.delay)
260
+ await self._access.check()
261
+ except Exception as error:
262
+ self._request.state.python_ddd_framework_transfer_failure = error
263
+ cancel_scope.cancel()
@@ -19,13 +19,8 @@ from ..application_services.errors import (
19
19
  )
20
20
  from ..auditing import ApplicationServiceAuditRecord, AuditSink
21
21
  from ..auditing.control import AuditingControl, AuditingOptions
22
- from ..authorization import AuthorizationOptions, PermissionChecker
22
+ from ..authorization.checking import _check_authorization
23
23
  from ..authorization.contracts import _AuthorizationRequirement
24
- from ..authorization.errors import (
25
- PermissionCheckerUnavailableError,
26
- PermissionDeniedError,
27
- UnauthenticatedError,
28
- )
29
24
  from ..invocation.arguments import _validate_arguments
30
25
  from ..invocation.execution import _InterceptorPipeline
31
26
  from ..invocation.interceptors import InterceptionStage
@@ -271,21 +266,4 @@ class _MethodDispatcher:
271
266
  )
272
267
 
273
268
  async def _authorize(self, requirement: _AuthorizationRequirement) -> None:
274
- if requirement.allows_anonymous or not requirement.requires_authenticated_user:
275
- return
276
- # 所有受管理入口共用此阶段;放行不改变认证器或 CurrentUser。
277
- options = await self._container.get(Options[AuthorizationOptions])
278
- if options.value.always_allow:
279
- return
280
- current_user = self._runtime.current_user()
281
- if not current_user.is_authenticated:
282
- raise UnauthenticatedError
283
- if not requirement.permissions:
284
- return
285
- try:
286
- checker = await self._container.get(PermissionChecker)
287
- except NoFactoryError:
288
- raise PermissionCheckerUnavailableError from None
289
- for permission in requirement.permissions:
290
- if not await checker.is_granted(current_user, permission):
291
- raise PermissionDeniedError(permission=str(permission))
269
+ await _check_authorization(self._container, self._runtime.current_user, requirement)
@@ -11,9 +11,11 @@ from .contracts import ApplicationInvocationContext
11
11
 
12
12
 
13
13
  @asynccontextmanager
14
- async def _managed_request(parent: AsyncContainer) -> AsyncIterator[AsyncContainer]:
15
- """排空入口自己创建的 REQUEST;不推断业务事务是否已经提交。"""
16
- request = parent(scope=Scope.REQUEST)
14
+ async def _managed_request(
15
+ parent: AsyncContainer, *, context: dict[type[object], object] | None = None
16
+ ) -> AsyncIterator[AsyncContainer]:
17
+ """显式传入原生 scope 输入并排空独立 REQUEST;不复制父容器缓存或推断事务结果。"""
18
+ request = parent(context=context, scope=Scope.REQUEST)
17
19
  primary: BaseException | None = None
18
20
  try:
19
21
  yield request
@@ -17,6 +17,7 @@ _DIAGNOSTIC_FIELDS = (
17
17
  "audit_contract",
18
18
  "audit_method",
19
19
  "audit_route_template",
20
+ "access_exception_type",
20
21
  )
21
22
  _HTTP_DIAGNOSTIC_LOGGERS = ("httpx", "httpcore", "uvicorn.access")
22
23
 
@@ -3,8 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections.abc import AsyncIterator, Callable
7
- from contextlib import AsyncExitStack, asynccontextmanager
6
+ from collections.abc import AsyncIterator, Callable, Iterator
7
+ from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
8
8
  from contextvars import ContextVar
9
9
  from functools import partial
10
10
  from typing import TYPE_CHECKING
@@ -61,6 +61,15 @@ class UnitOfWorkManager:
61
61
  raise RuntimeError("operation requires a transactional UnitOfWork")
62
62
  return work
63
63
 
64
+ @contextmanager
65
+ def _detached_work(self) -> Iterator[None]:
66
+ """受监督的阶段交接不继承旧事务;普通子任务仍使用 current 的 task guard。"""
67
+ token = self._ambient.set(None)
68
+ try:
69
+ yield
70
+ finally:
71
+ self._ambient.reset(token)
72
+
64
73
  def _resolve_options(
65
74
  self, connection_name: str | None, declaration: UnitOfWorkDeclaration, method_name: str
66
75
  ) -> _ResolvedUnitOfWorkOptions:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ddd-framework
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Modular DDD application framework with project CLI and bundled templates
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -127,28 +127,33 @@ pddd add module conversions --template basic
127
127
 
128
128
  `dev-init` starts or reuses local PostgreSQL and Redis using `backend/app.development.yaml`. It preserves existing data; database migration and seeding are separate steps.
129
129
 
130
- `add module orders` generates an order-management example and registers it with the Host. The example includes an aggregate, service contracts, application services, persistence, HTTP endpoints, module tests, and a module README describing its actual behavior and boundaries.
130
+ `add module orders` generates a DDD skeleton named `orders` and registers its Application, SQLAlchemy, and HttpApi Modules with the Host. It retains all responsibility directories, package markers, lifecycle hooks, ORM Base, model discovery, migration registration, and a module README. It creates no business entities, services, tables, endpoints, tasks, or test cases. Add them from confirmed requirements. The former order implementation is available as a separate [order-management example](examples/order_management/README.md), outside the runtime wheel.
131
131
 
132
132
  `ddd` is the default six-layer template. `basic` generates only a Module, a synchronous Protocol service, its transient implementation, and an English README. It has no database/Redis requirement or implicit HTTP/interception. Both templates support `--dry-run` and refuse existing targets.
133
133
 
134
134
  ### 3. Initialize the database
135
135
 
136
- Apply the provider migrations selected by the generated Host, then generate the business module's first migration:
136
+ Apply the provider migrations selected by the generated Host:
137
137
 
138
138
  ```sh
139
139
  pddd db upgrade --module identity
140
140
  pddd db upgrade --module settings
141
141
  pddd db upgrade --module auditing
142
142
  pddd db upgrade --module background_jobs
143
+ pddd db seed --module identity
144
+ ```
145
+
146
+ An empty DDD skeleton retains model and migration registration but has no business tables or revisions. Add actual models before generating a business revision; migration commands still use the configured database. For a module named `orders`:
147
+
148
+ ```sh
143
149
  pddd db revision --module orders
144
150
  ```
145
151
 
146
- Review the generated revision in `backend/src/modules/orders/sqlalchemy/migrations/` before applying it. Then upgrade and seed:
152
+ Review the generated revision in `backend/src/modules/orders/sqlalchemy/migrations/` before applying it. Run seed only after declaring the intended contributors:
147
153
 
148
154
  ```sh
149
155
  pddd db upgrade --module orders
150
156
  pddd db status --module orders
151
- pddd db seed --module identity
152
157
  pddd db seed --module orders
153
158
  ```
154
159
 
@@ -162,7 +167,7 @@ pddd dev
162
167
 
163
168
  Open **http://127.0.0.1:8000/docs**. Use the generated `identity.seed_admin_username` and `identity.seed_admin_password` in `backend/app.development.yaml` to call `/api/auth/login`, then enter the returned access token in Swagger's **Authorize** dialog.
164
169
 
165
- With the `orders` module installed, you can create and query orders at `/api/orders`, approve an order at `/api/orders/{id}/approve`, and enqueue approval at `/api/orders/{id}/queue-approval`. Swagger shows the request schemas and all available operations. The example also includes file transfer and an authenticated WebSocket endpoint at `/ws/orders`.
170
+ The empty `orders` skeleton contributes no business routes. The independent [order-management example](examples/order_management/README.md) supplies `/api/orders`, approval and queued approval, file transfer, and an authenticated WebSocket at `/ws/orders`; use its initializer when you want to explore those capabilities.
166
171
 
167
172
  The default business API prefix is `/api`. Liveness and readiness are exposed at `/health/live` and `/health/ready`; readiness describes application lifecycle state, not continuous database or Redis health.
168
173
 
@@ -295,6 +300,22 @@ The image uses `python -m host.main` without development reload. Runtime install
295
300
 
296
301
  <a id="版本与升级"></a>
297
302
 
303
+ ### DDD skeleton generation (0.7)
304
+
305
+ New `ddd` modules contain the full responsibility directories and necessary composition, with no preset business code or tests. `basic` is unchanged. This affects subsequent generation only: upgrades do not delete or rewrite existing consumers' order code. Keep existing business contracts and data under their application owners. The former order implementation and its existing domain test now live in [the independent example](examples/order_management/README.md); its initializer uses the official CLI, requires a new destination, and is not bundled in the runtime wheel. No CLI parameter or dependency has been added.
306
+
307
+ <a id="streaming-and-access-migration-07-unreleased"></a>
308
+
309
+ ## Streaming and access migration (0.7)
310
+
311
+ - Move `fastapi_realtime.authentication_refresh_seconds` to `fastapi_access.authentication_refresh_seconds`. The shared `FastApiAccessOptions` applies to managed HTTP streams and WebSocket connections; its default is 60 seconds and minimum is 1. The old realtime field is rejected.
312
+ - HTTP endpoints on `HttpRouter` can directly yield synchronous or asynchronous values using native FastAPI SSE, JSONL, or `StreamingResponse`. Keep checks required before the response starts in preparation dependencies or Filters. Authorization precedes binding; function dependencies, commit, and business resource cleanup finish before sending. The framework does not read the first item during preparation. Application services keep their existing return contract and may return a native streaming Response.
313
+ - Register subscriptions acquired during preparation with a native request-scoped yield dependency. An unstarted generator's `finally` cannot release those subscriptions. Database work in preparation or during iteration must use an independent short service call or UoW and finish before yielding data. Generator cleanup that awaits must shield its own `finally`, without holding a cancellation scope across `yield`.
314
+ - Streams with authorization declarations automatically recheck identity and endpoint permissions, including while idle or blocked on a slow client. Anonymous declarations retain their meaning. WebSocket `invoke` continues to authorize each service call separately. Failed checks terminate the transport; after `http.response.start`, no replacement JSON response is sent.
315
+ - `run_host(..., timeout_graceful_shutdown=10)` passes a non-negative integer to Uvicorn. When that period expires, cancellation is requested and framework drain still waits for actual resource release. Direct Uvicorn consumers must configure its native timeout themselves. A synchronous generator's running `next()` must eventually return; a timeout does not abandon its thread.
316
+
317
+ Runnable examples: [HTTP generators](docs/development.md#http-生成器与持续访问). Verification and limitations: [0.7.0 status](docs/status.md#070-通用生成器与持续访问未发布).
318
+
298
319
  ## Capability module migration (0.4)
299
320
 
300
321
  Capability composition now uses the same `AppModule` model as customer modules. Existing applications must migrate their declarations together; there is no compatibility path.
@@ -452,7 +473,7 @@ Channels that must accept these callbacks declare `stopping_owners=(ServiceType,
452
473
 
453
474
  The developer-kit dependency constrains `binaryornot` to `0.5.x`, because `0.6.0` can classify bundled templates containing Chinese comments as binary and skip rendering them. Re-resolve the developer-kit environment when installing 0.4.0. Updating the dependency does not replace existing generated application code.
454
475
 
455
- The new `DomainService` marker uses existing constructor injection and transient REQUEST lifetime. It does not create transactions or expose HTTP endpoints. Newly generated modules place approval policy in `domain/services/order_approval_service.py`; existing projects can adopt that example explicitly after upgrading. The application checks existence and the submitted version before the domain policy; a stale or missing order therefore takes precedence over a disabled approval setting when both inputs are invalid. Domain rules, schema, and installation files are not automatically rewritten.
476
+ The new `DomainService` marker uses existing constructor injection and transient REQUEST lifetime. It does not create transactions or expose HTTP endpoints. The independent order example places approval policy in `domain/services/order_approval_service.py`; existing projects can adopt it explicitly after upgrading. The example application checks existence and the submitted version before the domain policy; a stale or missing order therefore takes precedence over a disabled approval setting when both inputs are invalid. Domain rules, schema, and installation files are not automatically rewritten.
456
477
 
457
478
  <a id="框架更名迁移未发布"></a>
458
479
 
@@ -541,7 +562,7 @@ The engineering guides below are maintained in Chinese in the private GitHub rep
541
562
  | Check release evidence and remaining validation limits | [Status and validation](https://github.com/componet-architecture/python-ddd-framework/blob/main/docs/status.md) |
542
563
  | Change the framework itself | [Contributor guidance](https://github.com/componet-architecture/python-ddd-framework/blob/main/AGENTS.md) and [focused reading paths](https://github.com/componet-architecture/python-ddd-framework/blob/main/docs/README.md#重构的最短阅读路径) |
543
564
 
544
- Generated applications include a self-contained English README, `AGENTS.md`, architecture document, and development guide for their template version. `AGENTS.md` guides contributors from business rules and ownership to framework capability selection and verification. The development guide maps common tasks to framework capabilities and includes commands to locate the installed version's source and bundled examples; each DDD module README records the sample business model and traces approval through its owners. The application maintains these documents after generation; framework upgrades do not overwrite them. Existing applications can use their installed package's `developer_kit/templates` as a read-only reference when updating affected guidance, preserving their own business model and decisions.
565
+ Generated applications include a self-contained English README, `AGENTS.md`, architecture document, and development guide for their template version. They require an owner/layer/directory/API check before implementation and a review of the seven design principles, DDD boundaries, and actual verification before delivery. The development guide provides CLI entry points, installed-artifact/source comparison, capability recipes, and a complete native HTTP generator example; architecture owns streaming/access boundaries, and the generated README owns configuration migration and shutdown operations. These instructions do not imply an automated architecture gate. Each DDD module README records the empty skeleton and guides the first authorized use case; the independent order example owns its own business documentation and acceptance source. The application maintains these documents after generation; upgrades do not overwrite them. Existing applications can use their installed package's `developer_kit/templates` as a read-only reference while preserving application rules and decisions. A user-provided framework checkout is also read-only; matching version numbers alone do not prove it matches the consumed artifact.
545
566
 
546
567
  ## License
547
568