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.
- python_ddd_framework/authorization/checking.py +35 -0
- python_ddd_framework/developer_kit/generation.py +0 -9
- python_ddd_framework/developer_kit/templates/module/cookiecutter.json +1 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +29 -77
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +5 -72
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +1 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +2 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +8 -62
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +1 -2
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +12 -8
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +22 -10
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/backend/src/host/main.py.jinja +4 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +40 -6
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +177 -30
- python_ddd_framework/fastapi/__init__.py +2 -0
- python_ddd_framework/fastapi/access.py +127 -0
- python_ddd_framework/fastapi/access_options.py +13 -0
- python_ddd_framework/fastapi/action.py +6 -0
- python_ddd_framework/fastapi/manual_action.py +33 -2
- python_ddd_framework/fastapi/module.py +2 -0
- python_ddd_framework/fastapi/realtime/authentication.py +21 -10
- python_ddd_framework/fastapi/realtime/options.py +0 -1
- python_ddd_framework/fastapi/realtime/runtime.py +41 -26
- python_ddd_framework/fastapi/request_context.py +10 -2
- python_ddd_framework/fastapi/routing.py +12 -5
- python_ddd_framework/fastapi/server.py +9 -0
- python_ddd_framework/fastapi/service_endpoints.py +5 -0
- python_ddd_framework/fastapi/streaming.py +95 -0
- python_ddd_framework/fastapi/transfer.py +138 -3
- python_ddd_framework/invocation/dispatcher.py +2 -24
- python_ddd_framework/invocation/scopes.py +5 -3
- python_ddd_framework/observability/formatting.py +1 -0
- python_ddd_framework/unit_of_work/manager.py +11 -2
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/METADATA +29 -8
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/RECORD +39 -86
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/WHEEL +1 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/__init__.py.jinja +0 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/handler.py.jinja +0 -45
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/payload.py.jinja +0 -8
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/__init__.py.jinja +0 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/handler.py.jinja +0 -35
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/payload.py.jinja +0 -5
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/schedule.py.jinja +0 -15
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_maintenance_worker.py.jinja +0 -28
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_statistics_worker.py.jinja +0 -26
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/order_cache.py.jinja +0 -9
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/statistics_cache.py.jinja +0 -9
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/order_changed_handler.py.jinja +0 -23
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +0 -71
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +0 -16
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/order_reporting_service.py.jinja +0 -16
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/order_timing_interceptor.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options/order_options.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_approval_service.py.jinja +0 -65
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_management_service.py.jinja +0 -35
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_query_service.py.jinja +0 -41
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/approval_setting_observer.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/approve_order.py.jinja +0 -6
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/create_order.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/order_reporting_service.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_approval_service.py.jinja +0 -13
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_management_service.py.jinja +0 -10
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_query_service.py.jinja +0 -12
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_statistics_snapshot.py.jinja +0 -9
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_view.py.jinja +0 -12
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/entities/order.py.jinja +0 -48
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/order_changed.py.jinja +0 -13
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/order_repository.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/order_seed_contributor.py.jinja +0 -22
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/order_approval_service.py.jinja +0 -23
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings/approval_settings.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/order_title.py.jinja +0 -10
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/order_constants.py.jinja +0 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/order_status.py.jinja +0 -6
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/order_errors.py.jinja +0 -11
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_messages.py.jinja +0 -26
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_observation.py.jinja +0 -13
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permission_provider.py.jinja +0 -19
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permissions.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/money.py.jinja +0 -18
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/export_filter.py.jinja +0 -15
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/refresh_orders.py.jinja +0 -5
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/routers/order_files.py.jinja +0 -76
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/order_socket.py.jinja +0 -44
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/order_model.py.jinja +0 -29
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +0 -81
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +0 -19
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
@@ -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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|