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.
Files changed (93) 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/adapter.py +25 -10
  20. python_ddd_framework/fastapi/health.py +20 -11
  21. python_ddd_framework/fastapi/manual_action.py +33 -2
  22. python_ddd_framework/fastapi/module.py +2 -0
  23. python_ddd_framework/fastapi/realtime/authentication.py +21 -10
  24. python_ddd_framework/fastapi/realtime/options.py +0 -1
  25. python_ddd_framework/fastapi/realtime/runtime.py +41 -26
  26. python_ddd_framework/fastapi/request_context.py +10 -2
  27. python_ddd_framework/fastapi/routing.py +13 -6
  28. python_ddd_framework/fastapi/server.py +9 -0
  29. python_ddd_framework/fastapi/service_endpoints.py +5 -0
  30. python_ddd_framework/fastapi/streaming.py +95 -0
  31. python_ddd_framework/fastapi/transfer.py +138 -3
  32. python_ddd_framework/invocation/dispatcher.py +2 -24
  33. python_ddd_framework/invocation/scopes.py +5 -3
  34. python_ddd_framework/observability/formatting.py +1 -0
  35. python_ddd_framework/sqlalchemy/migration.py +17 -1
  36. python_ddd_framework/unit_of_work/manager.py +11 -2
  37. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/METADATA +38 -9
  38. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/RECORD +42 -89
  39. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/WHEEL +1 -1
  40. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/__init__.py.jinja +0 -1
  41. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/handler.py.jinja +0 -45
  42. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/payload.py.jinja +0 -8
  43. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/__init__.py.jinja +0 -1
  44. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/handler.py.jinja +0 -35
  45. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/payload.py.jinja +0 -5
  46. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/schedule.py.jinja +0 -15
  47. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_maintenance_worker.py.jinja +0 -28
  48. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_statistics_worker.py.jinja +0 -26
  49. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/order_cache.py.jinja +0 -9
  50. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/statistics_cache.py.jinja +0 -9
  51. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/order_changed_handler.py.jinja +0 -23
  52. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +0 -71
  53. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +0 -16
  54. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/order_reporting_service.py.jinja +0 -16
  55. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/order_timing_interceptor.py.jinja +0 -17
  56. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options/order_options.py.jinja +0 -7
  57. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_approval_service.py.jinja +0 -65
  58. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_management_service.py.jinja +0 -35
  59. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_query_service.py.jinja +0 -41
  60. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/approval_setting_observer.py.jinja +0 -17
  61. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/approve_order.py.jinja +0 -6
  62. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/create_order.py.jinja +0 -7
  63. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/order_reporting_service.py.jinja +0 -7
  64. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_approval_service.py.jinja +0 -13
  65. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_management_service.py.jinja +0 -10
  66. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_query_service.py.jinja +0 -12
  67. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_statistics_snapshot.py.jinja +0 -9
  68. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_view.py.jinja +0 -12
  69. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/entities/order.py.jinja +0 -48
  70. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/order_changed.py.jinja +0 -13
  71. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/order_repository.py.jinja +0 -17
  72. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/order_seed_contributor.py.jinja +0 -22
  73. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/order_approval_service.py.jinja +0 -23
  74. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings/approval_settings.py.jinja +0 -17
  75. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/order_title.py.jinja +0 -10
  76. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/order_constants.py.jinja +0 -3
  77. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/order_status.py.jinja +0 -6
  78. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/order_errors.py.jinja +0 -11
  79. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_messages.py.jinja +0 -26
  80. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_observation.py.jinja +0 -13
  81. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permission_provider.py.jinja +0 -19
  82. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permissions.py.jinja +0 -7
  83. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/money.py.jinja +0 -18
  84. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/export_filter.py.jinja +0 -15
  85. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/refresh_orders.py.jinja +0 -5
  86. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/routers/order_files.py.jinja +0 -76
  87. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/order_socket.py.jinja +0 -44
  88. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/order_model.py.jinja +0 -29
  89. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +0 -81
  90. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +0 -19
  91. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/entry_points.txt +0 -0
  92. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/licenses/LICENSE +0 -0
  93. {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
- return await authenticator.authenticate_access_token(token)
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
- if isinstance(route, _ActionRoute):
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 route._definition.authorization.permissions
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(application)
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
- 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
 
@@ -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
- if (owner is not None and source.owner != owner) or allow_empty:
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: