python-ddd-framework 0.6.0__py3-none-any.whl → 0.7.1__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/adapter.py +25 -10
- python_ddd_framework/fastapi/health.py +20 -11
- 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 +13 -6
- 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/sqlalchemy/migration.py +17 -1
- python_ddd_framework/unit_of_work/manager.py +11 -2
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/METADATA +38 -9
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/RECORD +42 -89
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.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.1.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
|
@@ -103,10 +103,12 @@ class _RequestContextMiddleware:
|
|
|
103
103
|
current_user = CurrentUser.anonymous()
|
|
104
104
|
status_code = 500
|
|
105
105
|
failure: BaseException | None = None
|
|
106
|
+
response_started = False
|
|
106
107
|
|
|
107
108
|
async def send_with_context(message: Message) -> None:
|
|
108
|
-
nonlocal status_code
|
|
109
|
+
nonlocal status_code, response_started
|
|
109
110
|
if message["type"] == "http.response.start":
|
|
111
|
+
response_started = True
|
|
110
112
|
status_code = cast(int, message["status"])
|
|
111
113
|
headers = list(message.get("headers", ()))
|
|
112
114
|
if not any(name.lower() == _CORRELATION_HEADER for name, _ in headers):
|
|
@@ -132,6 +134,9 @@ class _RequestContextMiddleware:
|
|
|
132
134
|
await self._app(scope, receive, send_with_context)
|
|
133
135
|
except UnauthenticatedError as original_error:
|
|
134
136
|
failure = original_error
|
|
137
|
+
if response_started:
|
|
138
|
+
# 响应头已发出时不能再构造第二个 401;传输 owner 负责终止流与清理。
|
|
139
|
+
raise
|
|
135
140
|
status_code = 401
|
|
136
141
|
response = JSONResponse(
|
|
137
142
|
status_code=401,
|
|
@@ -171,7 +176,10 @@ class _RequestContextMiddleware:
|
|
|
171
176
|
except NoFactoryError:
|
|
172
177
|
# Host 收到认证凭据却没有验证 authority 时必须 fail closed。
|
|
173
178
|
raise UnauthenticatedError from None
|
|
174
|
-
|
|
179
|
+
user = await authenticator.authenticate_access_token(token)
|
|
180
|
+
# 保留原凭据供长连接重新认证;握手身份快照不能代替后续有效性检查。
|
|
181
|
+
Request(scope).state.python_ddd_framework_access_token = token
|
|
182
|
+
return user
|
|
175
183
|
|
|
176
184
|
async def _write_audit(
|
|
177
185
|
self,
|
|
@@ -14,6 +14,7 @@ from fastapi.routing import APIRoute, APIWebSocketRoute, RouteContext, iter_rout
|
|
|
14
14
|
from starlette.routing import BaseRoute, WebSocketRoute
|
|
15
15
|
|
|
16
16
|
from ..application import Application
|
|
17
|
+
from ..authorization.contracts import _authorization_requirement
|
|
17
18
|
from ..authorization.module import AuthorizationModule
|
|
18
19
|
from ..diagnostics import SourceLocation
|
|
19
20
|
from ..diagnostics.source import _source_file_line
|
|
@@ -106,16 +107,22 @@ def _build_route_plan(
|
|
|
106
107
|
route.operation_id = cast(_OpenApiRouteView, context).unique_id
|
|
107
108
|
for context in iter_route_contexts(router.routes):
|
|
108
109
|
route = context.original_route
|
|
109
|
-
|
|
110
|
+
requirement = (
|
|
111
|
+
route._definition.authorization
|
|
112
|
+
if isinstance(route, _ActionRoute)
|
|
113
|
+
else _authorization_requirement(object, route.endpoint)
|
|
114
|
+
if isinstance(route, APIWebSocketRoute)
|
|
115
|
+
else None
|
|
116
|
+
)
|
|
117
|
+
if requirement is not None:
|
|
118
|
+
# 声明错误在路由安装时失败,不推迟到首个 HTTP 请求或 WebSocket 握手。
|
|
110
119
|
unknown = tuple(
|
|
111
120
|
permission
|
|
112
|
-
for permission in
|
|
121
|
+
for permission in requirement.permissions
|
|
113
122
|
if permission not in application.modules.instance(AuthorizationModule).catalog
|
|
114
123
|
)
|
|
115
124
|
if unknown:
|
|
116
|
-
raise FastApiAdapterError(
|
|
117
|
-
f"HTTP action references unknown permissions: {unknown}"
|
|
118
|
-
)
|
|
125
|
+
raise FastApiAdapterError(f"endpoint references unknown permissions: {unknown}")
|
|
119
126
|
routers.append((router, prefix))
|
|
120
127
|
route_start = len(preview.routes)
|
|
121
128
|
preview.include_router(router, prefix=prefix)
|
|
@@ -128,7 +135,7 @@ def _build_route_plan(
|
|
|
128
135
|
)
|
|
129
136
|
)
|
|
130
137
|
|
|
131
|
-
health_router = _create_health_router(
|
|
138
|
+
health_router = _create_health_router()
|
|
132
139
|
health_route_start = len(preview.routes)
|
|
133
140
|
preview.include_router(health_router)
|
|
134
141
|
health_candidates = _route_candidates(
|
|
@@ -153,11 +153,18 @@ def run_host(
|
|
|
153
153
|
reload: bool = False,
|
|
154
154
|
project: Path | None = None,
|
|
155
155
|
environment: str | None = None,
|
|
156
|
+
timeout_graceful_shutdown: int = 10,
|
|
156
157
|
) -> None:
|
|
157
158
|
"""通过原生 Uvicorn 运行 Host factory,并守卫单实例、reload 与实际退出。"""
|
|
158
159
|
# 保留原生 Config/Server/ChangeReload,平台仅补进程输出边界,不接管 ASGI 生命周期。
|
|
159
160
|
# reload 的父进程持锁直到所有子进程 join;子进程不重复占用主 Host 位置。
|
|
160
161
|
# 此边界早于 factory 加载和运行期初始化,DB CLI/TestApplication 不经过这里。
|
|
162
|
+
if (
|
|
163
|
+
not isinstance(timeout_graceful_shutdown, int)
|
|
164
|
+
or isinstance(timeout_graceful_shutdown, bool)
|
|
165
|
+
or timeout_graceful_shutdown < 0
|
|
166
|
+
):
|
|
167
|
+
raise ValueError("timeout_graceful_shutdown must be a non-negative integer")
|
|
161
168
|
project = Path.cwd() if project is None else project
|
|
162
169
|
environment = (
|
|
163
170
|
environment if environment is not None else os.getenv(ENVIRONMENT_VARIABLE, "development")
|
|
@@ -172,6 +179,8 @@ def run_host(
|
|
|
172
179
|
reload=reload,
|
|
173
180
|
log_config=None,
|
|
174
181
|
access_log=False,
|
|
182
|
+
# 超时交给 Uvicorn 请求取消;平台仍等待实际清理与生命周期排空完成。
|
|
183
|
+
timeout_graceful_shutdown=timeout_graceful_shutdown,
|
|
175
184
|
)
|
|
176
185
|
shutdown_requested = (
|
|
177
186
|
multiprocessing.get_context("spawn").Event() if config.should_reload else None
|
|
@@ -16,6 +16,7 @@ from ..application_services.catalog import _CompiledApplicationService
|
|
|
16
16
|
from ..application_services.dispatcher import _ApplicationServiceDispatcher
|
|
17
17
|
from ..diagnostics import SourceLocation
|
|
18
18
|
from ..invocation.methods import _CompiledMethod
|
|
19
|
+
from .access import _http_access
|
|
19
20
|
from .action import _HttpActionOwner, _request_parameter
|
|
20
21
|
from .errors import FastApiAdapterError
|
|
21
22
|
from .filters import _filters_for, _ordered_filters
|
|
@@ -69,6 +70,10 @@ class _ApplicationServiceRoute(DishkaRoute):
|
|
|
69
70
|
dispatcher = await container.get(_ApplicationServiceDispatcher)
|
|
70
71
|
|
|
71
72
|
async def bind() -> Response:
|
|
73
|
+
# 约定式服务返回原生流 Response 时,也沿用同一持续授权与传输 owner。
|
|
74
|
+
request.state.python_ddd_framework_access = await _http_access(
|
|
75
|
+
request, method.authorization
|
|
76
|
+
)
|
|
72
77
|
await _prepare_upload(request, method.implementation_method, self)
|
|
73
78
|
return await native_handler(request)
|
|
74
79
|
|
|
@@ -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
|
|
@@ -332,6 +332,11 @@ def _validate_revision_graph(
|
|
|
332
332
|
# walk_revisions 强制 Alembic 在任何 engine 创建前加载并校验组合 graph/depends_on。
|
|
333
333
|
script = ScriptDirectory.from_config(config)
|
|
334
334
|
revisions = tuple(script.walk_revisions())
|
|
335
|
+
empty_model_sources = {
|
|
336
|
+
(item.owner, item.registration.branch_label, item.migration_path)
|
|
337
|
+
for item in models.for_connection(connection_name)
|
|
338
|
+
if not item.registration.metadata.tables
|
|
339
|
+
}
|
|
335
340
|
for source in migration_sources:
|
|
336
341
|
if source.extension is not None:
|
|
337
342
|
_validate_extension_prerequisites(models, connection_name, source, script)
|
|
@@ -341,7 +346,18 @@ def _validate_revision_graph(
|
|
|
341
346
|
if Path(revision.path).resolve().parent == source.migration_path
|
|
342
347
|
)
|
|
343
348
|
if not owned_revisions:
|
|
344
|
-
|
|
349
|
+
# 仅全局操作豁免纯 ORM 骨架的缺失 revision;不跳过图或数据库 heads 检查。
|
|
350
|
+
# 同一 owner 可贡献多个来源,判定必须限定当前 connection 与具体分支/目录。
|
|
351
|
+
if (
|
|
352
|
+
(owner is not None and source.owner != owner)
|
|
353
|
+
or allow_empty
|
|
354
|
+
or (
|
|
355
|
+
owner is None
|
|
356
|
+
and source.extension is None
|
|
357
|
+
and (source.owner, source.branch_label, source.migration_path)
|
|
358
|
+
in empty_model_sources
|
|
359
|
+
)
|
|
360
|
+
):
|
|
345
361
|
continue
|
|
346
362
|
raise SqlAlchemyModelRegistrationError(
|
|
347
363
|
owner=str(source.owner),
|
|
@@ -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:
|