python-platform 0.1.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_platform/__init__.py +445 -0
- python_platform/application/__init__.py +6 -0
- python_platform/application/build_spec.py +37 -0
- python_platform/application/builder.py +260 -0
- python_platform/application/composition.py +219 -0
- python_platform/application/runtime.py +546 -0
- python_platform/application/runtime_hooks.py +327 -0
- python_platform/application/state.py +216 -0
- python_platform/application_services/__init__.py +54 -0
- python_platform/application_services/catalog.py +500 -0
- python_platform/application_services/contracts.py +188 -0
- python_platform/application_services/dispatcher.py +423 -0
- python_platform/application_services/errors.py +89 -0
- python_platform/application_services/execution.py +199 -0
- python_platform/application_services/interceptors.py +44 -0
- python_platform/application_services/invocation.py +427 -0
- python_platform/application_services/policies.py +46 -0
- python_platform/application_services/seeding.py +72 -0
- python_platform/application_services/signature.py +49 -0
- python_platform/application_services/validation.py +84 -0
- python_platform/auditing/__init__.py +12 -0
- python_platform/auditing/contracts.py +39 -0
- python_platform/auditing/control.py +48 -0
- python_platform/auditing/sqlalchemy/__init__.py +6 -0
- python_platform/auditing/sqlalchemy/migrations/0001_platform_auditing.py +45 -0
- python_platform/auditing/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/auditing/sqlalchemy/models.py +31 -0
- python_platform/auditing/sqlalchemy/module.py +32 -0
- python_platform/auditing/sqlalchemy/store.py +63 -0
- python_platform/authorization/__init__.py +38 -0
- python_platform/authorization/catalog.py +63 -0
- python_platform/authorization/contracts.py +241 -0
- python_platform/authorization/definition_discovery.py +81 -0
- python_platform/authorization/definitions.py +43 -0
- python_platform/authorization/errors.py +40 -0
- python_platform/background_execution/__init__.py +15 -0
- python_platform/background_execution/application.py +41 -0
- python_platform/background_execution/child.py +123 -0
- python_platform/background_execution/contracts.py +39 -0
- python_platform/background_execution/lifecycle.py +22 -0
- python_platform/background_execution/local.py +127 -0
- python_platform/background_execution/locks.py +31 -0
- python_platform/background_execution/management.py +17 -0
- python_platform/background_execution/module.py +10 -0
- python_platform/background_execution/permissions.py +14 -0
- python_platform/background_execution/processes.py +331 -0
- python_platform/background_jobs/__init__.py +31 -0
- python_platform/background_jobs/catalog.py +287 -0
- python_platform/background_jobs/contracts.py +202 -0
- python_platform/background_jobs/errors.py +28 -0
- python_platform/background_jobs/execution.py +201 -0
- python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
- python_platform/background_jobs/pgqueuer/__init__.py +6 -0
- python_platform/background_jobs/pgqueuer/enqueue.py +82 -0
- python_platform/background_jobs/pgqueuer/migrations/0001_pgqueuer_1_3_2.py +63 -0
- python_platform/background_jobs/pgqueuer/migrations/__init__.py +1 -0
- python_platform/background_jobs/pgqueuer/module.py +102 -0
- python_platform/background_jobs/pgqueuer/options.py +70 -0
- python_platform/background_jobs/pgqueuer/runtime.py +479 -0
- python_platform/background_jobs/pgqueuer/sql/pgqueuer_1_3_2_install.sql +118 -0
- python_platform/background_jobs/pgqueuer/supervision.py +201 -0
- python_platform/background_workers/__init__.py +22 -0
- python_platform/background_workers/catalog.py +96 -0
- python_platform/background_workers/contracts.py +124 -0
- python_platform/background_workers/errors.py +20 -0
- python_platform/background_workers/execution.py +133 -0
- python_platform/background_workers/runtime.py +203 -0
- python_platform/caching/__init__.py +11 -0
- python_platform/caching/catalog.py +73 -0
- python_platform/caching/contracts.py +73 -0
- python_platform/caching/errors.py +20 -0
- python_platform/cli/__init__.py +87 -0
- python_platform/cli/errors.py +5 -0
- python_platform/cli/project.py +69 -0
- python_platform/cli/runtime.py +68 -0
- python_platform/configuration/__init__.py +21 -0
- python_platform/configuration/composition.py +65 -0
- python_platform/configuration/contracts.py +68 -0
- python_platform/configuration/dotenv_source.py +51 -0
- python_platform/configuration/environment_source.py +38 -0
- python_platform/configuration/immutability.py +124 -0
- python_platform/configuration/input_shape.py +58 -0
- python_platform/configuration/merge.py +103 -0
- python_platform/configuration/root.py +215 -0
- python_platform/configuration/sources.py +519 -0
- python_platform/configuration/values.py +427 -0
- python_platform/configuration/yaml_source.py +51 -0
- python_platform/developer_kit/__init__.py +1 -0
- python_platform/developer_kit/generation.py +182 -0
- python_platform/developer_kit/project_metadata.py +45 -0
- python_platform/developer_kit/source.py +53 -0
- python_platform/developer_kit/templates/module/cookiecutter.json +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/cache.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/events.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration.py.jinja +46 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +29 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options.py.jinja +8 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/orders.py.jinja +81 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/tasks.py.jinja +86 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/orders.py.jinja +36 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/orders.py.jinja +57 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repository.py.jinja +12 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding.py.jinja +19 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings.py.jinja +16 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/definitions.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +7 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions.py.jinja +15 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/files.py.jinja +74 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +58 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/realtime.py.jinja +37 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/migrations/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models.py.jinja +26 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +32 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repository.py.jinja +49 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +18 -0
- python_platform/developer_kit/templates/project/cookiecutter.json +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.dockerignore +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.env.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.gitignore +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.python-version +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/Dockerfile +22 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +86 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/app.yaml.jinja +16 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.dev.yaml.jinja +24 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.production.yaml.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/pyproject.toml.jinja +28 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/main.py.jinja +52 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/module.py.jinja +38 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/conftest.py.jinja +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/host/test_http.py.jinja +101 -0
- python_platform/developer_kit/wiring.py +85 -0
- python_platform/diagnostics/__init__.py +23 -0
- python_platform/diagnostics/journal.py +174 -0
- python_platform/diagnostics/model.py +83 -0
- python_platform/diagnostics/source.py +25 -0
- python_platform/distributed_lock/__init__.py +6 -0
- python_platform/distributed_lock/contracts.py +20 -0
- python_platform/distributed_lock/options.py +19 -0
- python_platform/domain/__init__.py +13 -0
- python_platform/domain/aggregates.py +64 -0
- python_platform/domain/entities.py +33 -0
- python_platform/domain/errors.py +12 -0
- python_platform/domain/value_objects.py +31 -0
- python_platform/errors/__init__.py +75 -0
- python_platform/errors/base.py +13 -0
- python_platform/errors/business.py +53 -0
- python_platform/errors/configuration.py +119 -0
- python_platform/errors/diagnostics.py +12 -0
- python_platform/errors/lifecycle.py +164 -0
- python_platform/errors/modularity.py +100 -0
- python_platform/errors/services.py +100 -0
- python_platform/events/__init__.py +25 -0
- python_platform/events/aggregate.py +39 -0
- python_platform/events/catalog.py +136 -0
- python_platform/events/contracts.py +39 -0
- python_platform/events/contribution.py +128 -0
- python_platform/events/discovery.py +105 -0
- python_platform/events/errors.py +48 -0
- python_platform/events/runtime.py +192 -0
- python_platform/fastapi/__init__.py +49 -0
- python_platform/fastapi/action.py +175 -0
- python_platform/fastapi/adapter.py +381 -0
- python_platform/fastapi/application_services.py +647 -0
- python_platform/fastapi/background.py +42 -0
- python_platform/fastapi/contracts.py +278 -0
- python_platform/fastapi/errors.py +32 -0
- python_platform/fastapi/filters.py +111 -0
- python_platform/fastapi/health.py +44 -0
- python_platform/fastapi/http.py +55 -0
- python_platform/fastapi/http_router.py +212 -0
- python_platform/fastapi/manual_action.py +105 -0
- python_platform/fastapi/middleware.py +93 -0
- python_platform/fastapi/parameters.py +97 -0
- python_platform/fastapi/realtime/__init__.py +13 -0
- python_platform/fastapi/realtime/authentication.py +181 -0
- python_platform/fastapi/realtime/connection.py +166 -0
- python_platform/fastapi/realtime/module.py +41 -0
- python_platform/fastapi/realtime/options.py +50 -0
- python_platform/fastapi/realtime/runtime.py +423 -0
- python_platform/fastapi/request_context.py +244 -0
- python_platform/fastapi/route_integrity.py +88 -0
- python_platform/fastapi/routing.py +410 -0
- python_platform/fastapi/server.py +181 -0
- python_platform/fastapi/settings.py +40 -0
- python_platform/fastapi/tracing.py +62 -0
- python_platform/fastapi/transfer.py +128 -0
- python_platform/fastapi/upload_limits.py +63 -0
- python_platform/fastapi/uploads.py +94 -0
- python_platform/hosted_services/__init__.py +29 -0
- python_platform/hosted_services/bridge.py +420 -0
- python_platform/hosted_services/catalog.py +259 -0
- python_platform/hosted_services/contracts.py +55 -0
- python_platform/hosted_services/errors.py +53 -0
- python_platform/hosted_services/options.py +17 -0
- python_platform/hosted_services/runtime.py +267 -0
- python_platform/hosted_services/state.py +40 -0
- python_platform/hosting/__init__.py +4 -0
- python_platform/hosting/instance.py +40 -0
- python_platform/identity/__init__.py +73 -0
- python_platform/identity/application.py +287 -0
- python_platform/identity/contracts.py +283 -0
- python_platform/identity/errors.py +28 -0
- python_platform/identity/http_api.py +90 -0
- python_platform/identity/module.py +46 -0
- python_platform/identity/passwords.py +34 -0
- python_platform/identity/permissions.py +14 -0
- python_platform/identity/services.py +108 -0
- python_platform/identity/sqlalchemy/__init__.py +5 -0
- python_platform/identity/sqlalchemy/migrations/0001_platform_identity.py +151 -0
- python_platform/identity/sqlalchemy/migrations/0002_physical_delete.py +42 -0
- python_platform/identity/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/identity/sqlalchemy/models.py +85 -0
- python_platform/identity/sqlalchemy/module.py +62 -0
- python_platform/identity/sqlalchemy/stores.py +526 -0
- python_platform/identity/tokens.py +106 -0
- python_platform/invocation/__init__.py +3 -0
- python_platform/invocation/callables.py +171 -0
- python_platform/invocation/contracts.py +33 -0
- python_platform/invocation/entries.py +54 -0
- python_platform/invocation/function_runtime.py +48 -0
- python_platform/invocation/interception.py +184 -0
- python_platform/lifecycle/__init__.py +25 -0
- python_platform/lifecycle/composition.py +35 -0
- python_platform/lifecycle/context.py +31 -0
- python_platform/lifecycle/runtime.py +43 -0
- python_platform/lifecycle/state.py +20 -0
- python_platform/modularity/__init__.py +15 -0
- python_platform/modularity/contracts.py +90 -0
- python_platform/modularity/discovery.py +143 -0
- python_platform/modularity/graph.py +334 -0
- python_platform/modularity/registry.py +100 -0
- python_platform/modularity/selection.py +16 -0
- python_platform/notifications/__init__.py +19 -0
- python_platform/notifications/catalog.py +46 -0
- python_platform/notifications/contracts.py +107 -0
- python_platform/observability/__init__.py +5 -0
- python_platform/observability/context.py +43 -0
- python_platform/observability/export.py +73 -0
- python_platform/observability/formatting.py +89 -0
- python_platform/observability/logging.py +118 -0
- python_platform/observability/options.py +45 -0
- python_platform/observability/tracing.py +80 -0
- python_platform/options/__init__.py +19 -0
- python_platform/options/aliases.py +331 -0
- python_platform/options/contribution.py +62 -0
- python_platform/options/immutability.py +48 -0
- python_platform/options/input_keys.py +214 -0
- python_platform/options/issues.py +335 -0
- python_platform/options/models.py +114 -0
- python_platform/options/registry.py +280 -0
- python_platform/options/schema.py +488 -0
- python_platform/options/validation.py +120 -0
- python_platform/py.typed +0 -0
- python_platform/realtime/__init__.py +12 -0
- python_platform/realtime/contracts.py +28 -0
- python_platform/realtime/diagnostics.py +37 -0
- python_platform/realtime/messages.py +113 -0
- python_platform/redis/__init__.py +6 -0
- python_platform/redis/distributed_lock.py +212 -0
- python_platform/redis/lease_lock.py +36 -0
- python_platform/redis/module.py +50 -0
- python_platform/redis/notification_runtime.py +182 -0
- python_platform/redis/notifications.py +25 -0
- python_platform/redis/options.py +35 -0
- python_platform/redis/runtime.py +116 -0
- python_platform/services/__init__.py +39 -0
- python_platform/services/application_bindings.py +221 -0
- python_platform/services/arbitration.py +381 -0
- python_platform/services/binding.py +102 -0
- python_platform/services/contribution.py +438 -0
- python_platform/services/convention.py +292 -0
- python_platform/services/convention_contracts.py +94 -0
- python_platform/services/exposure.py +23 -0
- python_platform/services/framework_provider.py +178 -0
- python_platform/services/native_graph.py +172 -0
- python_platform/services/provider.py +204 -0
- python_platform/services/registration.py +101 -0
- python_platform/services/runtime.py +298 -0
- python_platform/settings/__init__.py +30 -0
- python_platform/settings/application.py +95 -0
- python_platform/settings/binding.py +33 -0
- python_platform/settings/catalog.py +121 -0
- python_platform/settings/changes.py +14 -0
- python_platform/settings/contracts.py +115 -0
- python_platform/settings/definition_discovery.py +71 -0
- python_platform/settings/definitions.py +41 -0
- python_platform/settings/errors.py +22 -0
- python_platform/settings/handlers.py +43 -0
- python_platform/settings/management.py +57 -0
- python_platform/settings/manager.py +89 -0
- python_platform/settings/module.py +7 -0
- python_platform/settings/notifications.py +39 -0
- python_platform/settings/permissions.py +14 -0
- python_platform/settings/provider.py +74 -0
- python_platform/settings/refresh.py +140 -0
- python_platform/settings/refresh_module.py +45 -0
- python_platform/settings/sqlalchemy/__init__.py +5 -0
- python_platform/settings/sqlalchemy/migrations/0001_platform_settings.py +31 -0
- python_platform/settings/sqlalchemy/migrations/0002_physical_delete.py +32 -0
- python_platform/settings/sqlalchemy/migrations/0003_version_tokens.py +39 -0
- python_platform/settings/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/settings/sqlalchemy/models.py +36 -0
- python_platform/settings/sqlalchemy/module.py +36 -0
- python_platform/settings/sqlalchemy/store.py +86 -0
- python_platform/settings/store.py +16 -0
- python_platform/settings/values.py +39 -0
- python_platform/sqlalchemy/__init__.py +51 -0
- python_platform/sqlalchemy/alembic_runtime/__init__.py +1 -0
- python_platform/sqlalchemy/alembic_runtime/env.py +27 -0
- python_platform/sqlalchemy/alembic_runtime/script.py.mako +14 -0
- python_platform/sqlalchemy/auditing.py +48 -0
- python_platform/sqlalchemy/errors.py +85 -0
- python_platform/sqlalchemy/metadata.py +538 -0
- python_platform/sqlalchemy/migration.py +346 -0
- python_platform/sqlalchemy/module.py +48 -0
- python_platform/sqlalchemy/module_migration.py +148 -0
- python_platform/sqlalchemy/options.py +66 -0
- python_platform/sqlalchemy/repository.py +33 -0
- python_platform/sqlalchemy/runtime.py +85 -0
- python_platform/sqlalchemy/session_provider.py +45 -0
- python_platform/sqlalchemy/unit_of_work.py +97 -0
- python_platform/testing/__init__.py +5 -0
- python_platform/testing/runtime.py +112 -0
- python_platform/unit_of_work/__init__.py +14 -0
- python_platform/unit_of_work/contracts.py +223 -0
- python_platform/unit_of_work/errors.py +27 -0
- python_platform/unit_of_work/manager.py +210 -0
- python_platform/unit_of_work/options.py +92 -0
- python_platform-0.1.1.dist-info/METADATA +122 -0
- python_platform-0.1.1.dist-info/RECORD +344 -0
- python_platform-0.1.1.dist-info/WHEEL +4 -0
- python_platform-0.1.1.dist-info/entry_points.txt +9 -0
- python_platform-0.1.1.dist-info/licenses/LICENSE +7 -0
- python_platform-0.1.1.dist-info/licenses/src/python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Aggregate pending events to UoW-owned local event queues."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
from ..domain import AggregateRoot
|
|
8
|
+
from ..unit_of_work.contracts import UnitOfWork
|
|
9
|
+
from .runtime import LocalEventBus
|
|
10
|
+
|
|
11
|
+
_TId = TypeVar("_TId")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AggregateEventCollector:
|
|
15
|
+
"""同一事件进入 DOMAIN 与 AFTER_COMMIT 队列,聚合只释放一次。"""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("_events",)
|
|
18
|
+
|
|
19
|
+
def __init__(self, events: LocalEventBus) -> None:
|
|
20
|
+
self._events = events
|
|
21
|
+
|
|
22
|
+
def collect(self, aggregate: AggregateRoot[_TId]) -> tuple[object, ...]:
|
|
23
|
+
if not isinstance(aggregate, AggregateRoot):
|
|
24
|
+
raise TypeError("aggregate must be AggregateRoot")
|
|
25
|
+
# 发布资格先于提取;拒绝非事务发布不能消耗聚合尚未交付的事件。
|
|
26
|
+
work = self._events._manager.require_current(transactional=True)
|
|
27
|
+
return _collect_aggregate_events(work, aggregate)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _collect_aggregate_events(
|
|
31
|
+
work: UnitOfWork, aggregate: AggregateRoot[_TId]
|
|
32
|
+
) -> tuple[object, ...]:
|
|
33
|
+
# 两个公开入口先验证事务 owner;收集不触发 flush,也不恢复已释放事件或自动重放。
|
|
34
|
+
assert work._event_queue is not None
|
|
35
|
+
released = aggregate.release_local_events()
|
|
36
|
+
for event in released:
|
|
37
|
+
work._event_queue.publish_domain(event)
|
|
38
|
+
work._event_queue.publish_after_commit(event)
|
|
39
|
+
return released
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Local Event phase、subscription provenance 与 immutable catalog。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from dishka import BaseScope, Scope
|
|
12
|
+
|
|
13
|
+
from ..diagnostics import SourceLocation
|
|
14
|
+
from ..modularity import ModuleKey, ModuleRegistry
|
|
15
|
+
from .contracts import LocalEventPhase
|
|
16
|
+
from .errors import EventContributionError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .contribution import EventContributionRegistry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class LocalEventSubscription:
|
|
24
|
+
event_type: type[object]
|
|
25
|
+
handler_type: type[object] | Callable[..., Awaitable[None]]
|
|
26
|
+
phase: LocalEventPhase
|
|
27
|
+
owner: ModuleKey
|
|
28
|
+
source: SourceLocation
|
|
29
|
+
reason: str
|
|
30
|
+
declaration_index: int
|
|
31
|
+
order: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LocalEventCatalog:
|
|
35
|
+
__slots__ = ("_by_phase_and_type", "_subscriptions")
|
|
36
|
+
|
|
37
|
+
def __init__(self, subscriptions: tuple[LocalEventSubscription, ...]) -> None:
|
|
38
|
+
grouped: dict[tuple[LocalEventPhase, type[object]], list[LocalEventSubscription]] = {}
|
|
39
|
+
for subscription in subscriptions:
|
|
40
|
+
grouped.setdefault((subscription.phase, subscription.event_type), []).append(
|
|
41
|
+
subscription
|
|
42
|
+
)
|
|
43
|
+
self._subscriptions = subscriptions
|
|
44
|
+
self._by_phase_and_type: Mapping[
|
|
45
|
+
tuple[LocalEventPhase, type[object]], tuple[LocalEventSubscription, ...]
|
|
46
|
+
] = MappingProxyType({key: tuple(values) for key, values in grouped.items()})
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def subscriptions(self) -> tuple[LocalEventSubscription, ...]:
|
|
50
|
+
return self._subscriptions
|
|
51
|
+
|
|
52
|
+
def for_event(
|
|
53
|
+
self,
|
|
54
|
+
event_type: type[object],
|
|
55
|
+
phase: LocalEventPhase,
|
|
56
|
+
) -> tuple[LocalEventSubscription, ...]:
|
|
57
|
+
return self._by_phase_and_type.get((phase, event_type), ())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_event_catalog(
|
|
61
|
+
registry: ModuleRegistry,
|
|
62
|
+
contributions: EventContributionRegistry,
|
|
63
|
+
) -> LocalEventCatalog:
|
|
64
|
+
module_positions = {
|
|
65
|
+
descriptor.key: index for index, descriptor in enumerate(registry.dependency_order)
|
|
66
|
+
}
|
|
67
|
+
entries = sorted(
|
|
68
|
+
contributions.freeze(),
|
|
69
|
+
key=lambda item: (module_positions[item.owner], not item.automatic, item.declaration_index),
|
|
70
|
+
)
|
|
71
|
+
seen: dict[tuple[type[object], object, LocalEventPhase], SourceLocation] = {}
|
|
72
|
+
subscriptions: list[LocalEventSubscription] = []
|
|
73
|
+
for entry in entries:
|
|
74
|
+
identity = (entry.event_type, entry.handler_type, entry.phase)
|
|
75
|
+
if identity in seen:
|
|
76
|
+
raise EventContributionError(
|
|
77
|
+
reason=(
|
|
78
|
+
f"duplicate {entry.phase.value} subscription for "
|
|
79
|
+
f"{entry.event_type.__qualname__}/{entry.handler_type.__qualname__}; "
|
|
80
|
+
f"first={seen[identity]}, duplicate={entry.source}"
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
seen[identity] = entry.source
|
|
84
|
+
handler = (
|
|
85
|
+
inspect.getattr_static(entry.handler_type, "handle", None)
|
|
86
|
+
if isinstance(entry.handler_type, type)
|
|
87
|
+
else entry.handler_type
|
|
88
|
+
)
|
|
89
|
+
if isinstance(handler, (staticmethod, classmethod)) or not inspect.iscoroutinefunction(
|
|
90
|
+
handler
|
|
91
|
+
):
|
|
92
|
+
raise EventContributionError(
|
|
93
|
+
reason=(
|
|
94
|
+
f"{entry.handler_type.__module__}.{entry.handler_type.__qualname__}.handle "
|
|
95
|
+
"must be an instance async method"
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
subscriptions.append(
|
|
99
|
+
LocalEventSubscription(
|
|
100
|
+
event_type=entry.event_type,
|
|
101
|
+
handler_type=entry.handler_type,
|
|
102
|
+
phase=entry.phase,
|
|
103
|
+
owner=entry.owner,
|
|
104
|
+
source=entry.source,
|
|
105
|
+
reason=entry.reason,
|
|
106
|
+
declaration_index=entry.declaration_index,
|
|
107
|
+
order=len(subscriptions),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return LocalEventCatalog(tuple(subscriptions))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _validate_event_scopes(
|
|
114
|
+
catalog: LocalEventCatalog,
|
|
115
|
+
accepted_single_scopes: Mapping[type[object], BaseScope],
|
|
116
|
+
) -> None:
|
|
117
|
+
# 原生 alias 的生命周期只有最终 Dishka 图才能确定,不在贡献阶段猜测。
|
|
118
|
+
for subscription in catalog.subscriptions:
|
|
119
|
+
expected_scope = (
|
|
120
|
+
Scope.ACTION if subscription.phase is LocalEventPhase.DOMAIN else Scope.REQUEST
|
|
121
|
+
)
|
|
122
|
+
actual_scope = (
|
|
123
|
+
accepted_single_scopes.get(subscription.handler_type)
|
|
124
|
+
if isinstance(subscription.handler_type, type)
|
|
125
|
+
else expected_scope
|
|
126
|
+
)
|
|
127
|
+
if actual_scope is not expected_scope:
|
|
128
|
+
actual_label = "unregistered" if actual_scope is None else actual_scope.name
|
|
129
|
+
raise EventContributionError(
|
|
130
|
+
reason=(
|
|
131
|
+
f"{subscription.handler_type.__module__}."
|
|
132
|
+
f"{subscription.handler_type.__qualname__} must be "
|
|
133
|
+
f"independently resolvable from an accepted single registration in "
|
|
134
|
+
f"{expected_scope.name} scope, got {actual_label}"
|
|
135
|
+
)
|
|
136
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Local Event 稳定 phase contract。"""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
from .errors import EventContributionError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LocalEventPhase(str, Enum):
|
|
13
|
+
DOMAIN = "domain"
|
|
14
|
+
AFTER_COMMIT = "after_commit"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_EVENT_HANDLER_ATTRIBUTE = "__python_platform_local_event_handler__"
|
|
18
|
+
_THandler = TypeVar("_THandler")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class _LocalEventHandlerDefinition:
|
|
23
|
+
phase: LocalEventPhase
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def local_event_handler(*, phase: LocalEventPhase) -> Callable[[type[_THandler]], type[_THandler]]:
|
|
27
|
+
"""只保存 class-local phase;事件类型在 Application build 由 handle 注解解释。"""
|
|
28
|
+
if not isinstance(phase, LocalEventPhase):
|
|
29
|
+
raise EventContributionError(reason="phase must be LocalEventPhase")
|
|
30
|
+
|
|
31
|
+
def decorate(handler: type[_THandler]) -> type[_THandler]:
|
|
32
|
+
if not isinstance(handler, type) or inspect.isabstract(handler):
|
|
33
|
+
raise EventContributionError(reason="local_event_handler requires a concrete class")
|
|
34
|
+
if _EVENT_HANDLER_ATTRIBUTE in handler.__dict__:
|
|
35
|
+
raise EventContributionError(reason="local_event_handler is already declared")
|
|
36
|
+
setattr(handler, _EVENT_HANDLER_ATTRIBUTE, _LocalEventHandlerDefinition(phase))
|
|
37
|
+
return handler
|
|
38
|
+
|
|
39
|
+
return decorate
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""一次 Application composition 的 Local Event subscription collector。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from ..diagnostics import LifecycleStage, SourceLocation
|
|
10
|
+
from ..diagnostics.source import caller_source
|
|
11
|
+
from ..modularity import ModuleDescriptor, ModuleKey
|
|
12
|
+
from .contracts import LocalEventPhase
|
|
13
|
+
from .errors import EventContributionError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class _EventContribution:
|
|
18
|
+
event_type: type[object]
|
|
19
|
+
handler_type: type[object] | Callable[..., Awaitable[None]]
|
|
20
|
+
phase: LocalEventPhase
|
|
21
|
+
owner: ModuleKey
|
|
22
|
+
source: SourceLocation
|
|
23
|
+
reason: str
|
|
24
|
+
declaration_index: int
|
|
25
|
+
automatic: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EventContributor:
|
|
29
|
+
"""只在当前 Module.configure 有效,避免 runtime 或 global subscription mutation。"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_active", "_descriptor", "_registry", "_stage")
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
registry: EventContributionRegistry,
|
|
36
|
+
*,
|
|
37
|
+
descriptor: ModuleDescriptor,
|
|
38
|
+
stage: LifecycleStage,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._registry = registry
|
|
41
|
+
self._descriptor = descriptor
|
|
42
|
+
self._stage = stage
|
|
43
|
+
self._active = True
|
|
44
|
+
|
|
45
|
+
def subscribe(
|
|
46
|
+
self,
|
|
47
|
+
event_type: type[object],
|
|
48
|
+
handler_type: type[object] | Callable[..., Awaitable[None]],
|
|
49
|
+
phase: LocalEventPhase,
|
|
50
|
+
*,
|
|
51
|
+
reason: str,
|
|
52
|
+
) -> None:
|
|
53
|
+
if not self._active:
|
|
54
|
+
raise EventContributionError(
|
|
55
|
+
reason=f"event contributor is closed for {self._stage.value}"
|
|
56
|
+
)
|
|
57
|
+
self._registry.add(
|
|
58
|
+
event_type=event_type,
|
|
59
|
+
handler_type=handler_type,
|
|
60
|
+
phase=phase,
|
|
61
|
+
descriptor=self._descriptor,
|
|
62
|
+
source=caller_source(str(self._descriptor.key), depth=2),
|
|
63
|
+
reason=reason,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _close(self) -> None:
|
|
67
|
+
self._active = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class EventContributionRegistry:
|
|
71
|
+
"""Application-owned append-only subscription state,freeze 后只读。"""
|
|
72
|
+
|
|
73
|
+
__slots__ = ("_counts", "_entries", "_frozen")
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self._entries: list[_EventContribution] = []
|
|
77
|
+
self._counts: dict[ModuleKey, int] = {}
|
|
78
|
+
self._frozen = False
|
|
79
|
+
|
|
80
|
+
def contributor(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
descriptor: ModuleDescriptor,
|
|
84
|
+
stage: LifecycleStage,
|
|
85
|
+
) -> EventContributor:
|
|
86
|
+
return EventContributor(self, descriptor=descriptor, stage=stage)
|
|
87
|
+
|
|
88
|
+
def add(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
event_type: type[object],
|
|
92
|
+
handler_type: type[object] | Callable[..., Awaitable[None]],
|
|
93
|
+
phase: LocalEventPhase,
|
|
94
|
+
descriptor: ModuleDescriptor,
|
|
95
|
+
source: SourceLocation,
|
|
96
|
+
reason: str,
|
|
97
|
+
automatic: bool = False,
|
|
98
|
+
) -> None:
|
|
99
|
+
if self._frozen:
|
|
100
|
+
raise EventContributionError(reason="event contribution registry is already frozen")
|
|
101
|
+
if not isinstance(event_type, type) or not (
|
|
102
|
+
isinstance(handler_type, type) or inspect.iscoroutinefunction(handler_type)
|
|
103
|
+
):
|
|
104
|
+
raise EventContributionError(
|
|
105
|
+
reason="event_type must be a class; handler must be a class or async function"
|
|
106
|
+
)
|
|
107
|
+
if not isinstance(phase, LocalEventPhase):
|
|
108
|
+
raise EventContributionError(reason="phase must be LocalEventPhase")
|
|
109
|
+
if not isinstance(reason, str) or not (normalized_reason := reason.strip()):
|
|
110
|
+
raise EventContributionError(reason="subscription reason must be a non-empty string")
|
|
111
|
+
declaration_index = self._counts.get(descriptor.key, 0)
|
|
112
|
+
self._counts[descriptor.key] = declaration_index + 1
|
|
113
|
+
self._entries.append(
|
|
114
|
+
_EventContribution(
|
|
115
|
+
event_type=event_type,
|
|
116
|
+
handler_type=handler_type,
|
|
117
|
+
phase=phase,
|
|
118
|
+
owner=descriptor.key,
|
|
119
|
+
source=source,
|
|
120
|
+
reason=normalized_reason,
|
|
121
|
+
declaration_index=declaration_index,
|
|
122
|
+
automatic=automatic,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def freeze(self) -> tuple[_EventContribution, ...]:
|
|
127
|
+
self._frozen = True
|
|
128
|
+
return tuple(self._entries)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""解释冻结发现结果,形成一次 Application 的有序自动订阅贡献。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import types
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, Union, cast, get_args, get_origin
|
|
9
|
+
|
|
10
|
+
from dishka import Scope
|
|
11
|
+
|
|
12
|
+
from ..invocation.callables import _resolved_parameter_hints
|
|
13
|
+
from ..modularity import ModuleKey, ModuleRegistry
|
|
14
|
+
from ..modularity.discovery import _DiscoveredType
|
|
15
|
+
from .contracts import _EVENT_HANDLER_ATTRIBUTE, LocalEventPhase, _LocalEventHandlerDefinition
|
|
16
|
+
from .contribution import EventContributionRegistry
|
|
17
|
+
from .errors import EventContributionError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _discover_event_handlers(
|
|
21
|
+
registry: ModuleRegistry,
|
|
22
|
+
discoveries: tuple[_DiscoveredType, ...],
|
|
23
|
+
contributions: EventContributionRegistry,
|
|
24
|
+
) -> dict[type[object], Scope]:
|
|
25
|
+
positions = {item.key: index for index, item in enumerate(registry.dependency_order)}
|
|
26
|
+
owners: dict[type[object], ModuleKey] = {}
|
|
27
|
+
scopes: dict[type[object], Scope] = {}
|
|
28
|
+
for discovery in sorted(
|
|
29
|
+
discoveries,
|
|
30
|
+
key=lambda item: (
|
|
31
|
+
positions[item.owner],
|
|
32
|
+
f"{item.candidate.__module__}.{item.candidate.__qualname__}",
|
|
33
|
+
),
|
|
34
|
+
):
|
|
35
|
+
candidate = discovery.candidate
|
|
36
|
+
metadata = candidate.__dict__.get(_EVENT_HANDLER_ATTRIBUTE)
|
|
37
|
+
if metadata is None or inspect.isabstract(candidate):
|
|
38
|
+
continue
|
|
39
|
+
if not isinstance(metadata, _LocalEventHandlerDefinition):
|
|
40
|
+
raise EventContributionError(reason=f"{discovery.source}: invalid event metadata")
|
|
41
|
+
if candidate in owners:
|
|
42
|
+
if owners[candidate] != discovery.owner:
|
|
43
|
+
raise EventContributionError(
|
|
44
|
+
reason=f"{discovery.source}: handler is already owned by {owners[candidate]}"
|
|
45
|
+
)
|
|
46
|
+
continue
|
|
47
|
+
owners[candidate] = discovery.owner
|
|
48
|
+
scopes[candidate] = (
|
|
49
|
+
Scope.ACTION if metadata.phase is LocalEventPhase.DOMAIN else Scope.REQUEST
|
|
50
|
+
)
|
|
51
|
+
for event_type in _handler_event_types(candidate):
|
|
52
|
+
contributions.add(
|
|
53
|
+
event_type=event_type,
|
|
54
|
+
handler_type=candidate,
|
|
55
|
+
phase=metadata.phase,
|
|
56
|
+
descriptor=registry.descriptors[discovery.owner],
|
|
57
|
+
source=discovery.source,
|
|
58
|
+
reason="local_event_handler declaration",
|
|
59
|
+
automatic=True,
|
|
60
|
+
)
|
|
61
|
+
return scopes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _handler_event_types(candidate: type[object]) -> tuple[type[object], ...]:
|
|
65
|
+
method = inspect.getattr_static(candidate, "handle", None)
|
|
66
|
+
if isinstance(method, (classmethod, staticmethod)) or not inspect.iscoroutinefunction(method):
|
|
67
|
+
raise EventContributionError(
|
|
68
|
+
reason=f"{candidate.__qualname__}.handle must be an instance async method"
|
|
69
|
+
)
|
|
70
|
+
parameters = tuple(inspect.signature(method).parameters.values())
|
|
71
|
+
if len(parameters) < 2 or parameters[1].kind not in (
|
|
72
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
73
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
74
|
+
):
|
|
75
|
+
raise EventContributionError(
|
|
76
|
+
reason=f"{candidate.__qualname__}.handle requires an event argument"
|
|
77
|
+
)
|
|
78
|
+
event = parameters[1]
|
|
79
|
+
try:
|
|
80
|
+
hints = _resolved_parameter_hints(
|
|
81
|
+
cast(Callable[..., object], method), {event.name: event.annotation}
|
|
82
|
+
)
|
|
83
|
+
except TypeError as error:
|
|
84
|
+
raise EventContributionError(
|
|
85
|
+
reason=f"{candidate.__qualname__}.handle: unresolved event annotation"
|
|
86
|
+
) from error
|
|
87
|
+
annotation = hints[event.name]
|
|
88
|
+
choices = (
|
|
89
|
+
get_args(annotation)
|
|
90
|
+
if get_origin(annotation) in (Union, types.UnionType)
|
|
91
|
+
else (annotation,)
|
|
92
|
+
)
|
|
93
|
+
if any(
|
|
94
|
+
not isinstance(item, type)
|
|
95
|
+
or item in (Any, type(None), inspect.Parameter.empty)
|
|
96
|
+
or get_origin(item) is not None
|
|
97
|
+
for item in choices
|
|
98
|
+
):
|
|
99
|
+
raise EventContributionError(
|
|
100
|
+
reason=(
|
|
101
|
+
f"{candidate.__qualname__}.handle requires concrete event types, "
|
|
102
|
+
"without Any/None/generics"
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return cast(tuple[type[object], ...], choices)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Local Event contribution、cycle 与 phase-specific failure contracts。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
from ..errors.base import FrameworkError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EventContributionError(FrameworkError):
|
|
11
|
+
def __init__(self, *, reason: str) -> None:
|
|
12
|
+
self.reason = reason
|
|
13
|
+
super().__init__(f"Invalid local event contribution: {reason}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventCycleError(FrameworkError):
|
|
17
|
+
def __init__(self, *, event_type: type[object], ancestry: tuple[type[object], ...]) -> None:
|
|
18
|
+
self.event_type = event_type
|
|
19
|
+
self.ancestry = ancestry
|
|
20
|
+
rendered = " -> ".join(item.__qualname__ for item in (*ancestry, event_type))
|
|
21
|
+
super().__init__(f"Local event cycle detected: {rendered}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LocalEventHandlerError(FrameworkError):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
event_type: type[object],
|
|
29
|
+
handler_type: type[object] | Callable[..., Awaitable[None]],
|
|
30
|
+
original_error: BaseException,
|
|
31
|
+
committed: bool,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.event_type = event_type
|
|
34
|
+
self.handler_type = handler_type
|
|
35
|
+
self.original_error = original_error
|
|
36
|
+
self.committed = committed
|
|
37
|
+
super().__init__(
|
|
38
|
+
f"Local event handler {handler_type.__module__}.{handler_type.__qualname__} failed "
|
|
39
|
+
f"for {event_type.__module__}.{event_type.__qualname__} "
|
|
40
|
+
f"(committed={committed}, error={type(original_error).__name__})"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PostCommitEventError(FrameworkError):
|
|
45
|
+
def __init__(self, *, failures: tuple[LocalEventHandlerError, ...]) -> None:
|
|
46
|
+
self.failures = failures
|
|
47
|
+
self.committed = True
|
|
48
|
+
super().__init__(f"Post-commit local event dispatch failed ({len(failures)} failure(s))")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""UoW-owned FIFO envelopes 与 domain/post-commit dispatch。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
|
+
|
|
10
|
+
from dishka import AsyncContainer, Scope
|
|
11
|
+
|
|
12
|
+
from ..invocation import ApplicationInvocationContext
|
|
13
|
+
from ..unit_of_work.manager import UnitOfWorkManager
|
|
14
|
+
from .catalog import LocalEventCatalog, LocalEventSubscription
|
|
15
|
+
from .contracts import LocalEventPhase
|
|
16
|
+
from .errors import EventCycleError, LocalEventHandlerError, PostCommitEventError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class _EventEnvelope:
|
|
21
|
+
envelope_id: UUID
|
|
22
|
+
invocation_id: UUID
|
|
23
|
+
parent_envelope_id: UUID | None
|
|
24
|
+
event: object
|
|
25
|
+
event_type: type[object]
|
|
26
|
+
ancestry: tuple[type[object], ...]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _EventHandler(Protocol):
|
|
30
|
+
async def handle(self, event: object) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _LocalEventQueue:
|
|
34
|
+
"""队列只归一个具体 UoW;handler resolution 仍使用 Dishka 原生 scope。"""
|
|
35
|
+
|
|
36
|
+
__slots__ = (
|
|
37
|
+
"_action_container",
|
|
38
|
+
"_after_commit",
|
|
39
|
+
"_catalog",
|
|
40
|
+
"_context",
|
|
41
|
+
"_current",
|
|
42
|
+
"_domain",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
container: AsyncContainer,
|
|
48
|
+
context: ApplicationInvocationContext,
|
|
49
|
+
catalog: LocalEventCatalog,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._action_container = container
|
|
52
|
+
self._context = context
|
|
53
|
+
self._catalog = catalog
|
|
54
|
+
self._domain: deque[_EventEnvelope] = deque()
|
|
55
|
+
self._after_commit: deque[_EventEnvelope] = deque()
|
|
56
|
+
self._current: _EventEnvelope | None = None
|
|
57
|
+
|
|
58
|
+
def publish_domain(self, event: object) -> None:
|
|
59
|
+
self._publish(event, self._domain)
|
|
60
|
+
|
|
61
|
+
def publish_after_commit(self, event: object) -> None:
|
|
62
|
+
self._publish(event, self._after_commit)
|
|
63
|
+
|
|
64
|
+
async def _dispatch_domain(self) -> None:
|
|
65
|
+
request = (
|
|
66
|
+
self._action_container.parent_container
|
|
67
|
+
if self._action_container.scope is Scope.ACTION
|
|
68
|
+
else self._action_container
|
|
69
|
+
)
|
|
70
|
+
assert request is not None
|
|
71
|
+
# 每个 UoW 的 handler scope 独立;手动 UoW 的原调用 ACTION 可能已返回。
|
|
72
|
+
from ..application_services.invocation import _managed_action
|
|
73
|
+
|
|
74
|
+
async with _managed_action(request, self._context, committed=lambda: False) as action:
|
|
75
|
+
await self._drain_domain(action)
|
|
76
|
+
|
|
77
|
+
async def _drain_domain(self, action: AsyncContainer) -> None:
|
|
78
|
+
while self._domain:
|
|
79
|
+
envelope = self._domain.popleft()
|
|
80
|
+
await self._dispatch_envelope(
|
|
81
|
+
envelope,
|
|
82
|
+
action,
|
|
83
|
+
LocalEventPhase.DOMAIN,
|
|
84
|
+
committed=False,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def _dispatch_after_commit(self) -> None:
|
|
88
|
+
request_container = (
|
|
89
|
+
self._action_container.parent_container
|
|
90
|
+
if self._action_container.scope is Scope.ACTION
|
|
91
|
+
else self._action_container
|
|
92
|
+
)
|
|
93
|
+
if request_container is None or request_container.registry.scope is not Scope.REQUEST:
|
|
94
|
+
raise RuntimeError("ACTION container has no REQUEST parent for post-commit dispatch")
|
|
95
|
+
failures: list[LocalEventHandlerError] = []
|
|
96
|
+
while self._after_commit:
|
|
97
|
+
envelope = self._after_commit.popleft()
|
|
98
|
+
for subscription in self._catalog.for_event(
|
|
99
|
+
envelope.event_type,
|
|
100
|
+
LocalEventPhase.AFTER_COMMIT,
|
|
101
|
+
):
|
|
102
|
+
try:
|
|
103
|
+
await self._invoke_handler(
|
|
104
|
+
request_container,
|
|
105
|
+
subscription,
|
|
106
|
+
envelope,
|
|
107
|
+
committed=True,
|
|
108
|
+
)
|
|
109
|
+
except LocalEventHandlerError as failure:
|
|
110
|
+
failures.append(failure)
|
|
111
|
+
if failures:
|
|
112
|
+
raise PostCommitEventError(failures=tuple(failures))
|
|
113
|
+
|
|
114
|
+
def _publish(self, event: object, queue: deque[_EventEnvelope]) -> None:
|
|
115
|
+
event_type = type(event)
|
|
116
|
+
parent = self._current
|
|
117
|
+
ancestry = parent.ancestry if parent is not None else ()
|
|
118
|
+
if event_type in ancestry:
|
|
119
|
+
raise EventCycleError(event_type=event_type, ancestry=ancestry)
|
|
120
|
+
queue.append(
|
|
121
|
+
_EventEnvelope(
|
|
122
|
+
envelope_id=uuid4(),
|
|
123
|
+
invocation_id=self._context.invocation_id,
|
|
124
|
+
parent_envelope_id=parent.envelope_id if parent is not None else None,
|
|
125
|
+
event=event,
|
|
126
|
+
event_type=event_type,
|
|
127
|
+
ancestry=(*ancestry, event_type),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def _dispatch_envelope(
|
|
132
|
+
self,
|
|
133
|
+
envelope: _EventEnvelope,
|
|
134
|
+
container: AsyncContainer,
|
|
135
|
+
phase: LocalEventPhase,
|
|
136
|
+
*,
|
|
137
|
+
committed: bool,
|
|
138
|
+
) -> None:
|
|
139
|
+
for subscription in self._catalog.for_event(envelope.event_type, phase):
|
|
140
|
+
await self._invoke_handler(
|
|
141
|
+
container,
|
|
142
|
+
subscription,
|
|
143
|
+
envelope,
|
|
144
|
+
committed=committed,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def _invoke_handler(
|
|
148
|
+
self,
|
|
149
|
+
container: AsyncContainer,
|
|
150
|
+
subscription: LocalEventSubscription,
|
|
151
|
+
envelope: _EventEnvelope,
|
|
152
|
+
*,
|
|
153
|
+
committed: bool,
|
|
154
|
+
) -> None:
|
|
155
|
+
previous = self._current
|
|
156
|
+
self._current = envelope
|
|
157
|
+
try:
|
|
158
|
+
from ..application_services.invocation import _InvocationRuntime
|
|
159
|
+
from ..invocation.interception import _EntryInterceptionCatalog
|
|
160
|
+
|
|
161
|
+
entries = await container.get(_EntryInterceptionCatalog)
|
|
162
|
+
runtime = await container.get(_InvocationRuntime)
|
|
163
|
+
await entries.invoke(
|
|
164
|
+
runtime, container, subscription.handler_type, "handle", (envelope.event,)
|
|
165
|
+
)
|
|
166
|
+
except Exception as original_error:
|
|
167
|
+
error = LocalEventHandlerError(
|
|
168
|
+
event_type=envelope.event_type,
|
|
169
|
+
handler_type=subscription.handler_type,
|
|
170
|
+
original_error=original_error,
|
|
171
|
+
committed=committed,
|
|
172
|
+
)
|
|
173
|
+
raise error from original_error
|
|
174
|
+
finally:
|
|
175
|
+
self._current = previous
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class LocalEventBus:
|
|
179
|
+
"""发布能力解析具体 UoW 的队列;注入本身不创建 UoW 或资源。"""
|
|
180
|
+
|
|
181
|
+
def __init__(self, manager: UnitOfWorkManager) -> None:
|
|
182
|
+
self._manager = manager
|
|
183
|
+
|
|
184
|
+
def publish_domain(self, event: object) -> None:
|
|
185
|
+
work = self._manager.require_current(transactional=True)
|
|
186
|
+
assert work._event_queue is not None
|
|
187
|
+
work._event_queue.publish_domain(event)
|
|
188
|
+
|
|
189
|
+
def publish_after_commit(self, event: object) -> None:
|
|
190
|
+
work = self._manager.require_current(transactional=True)
|
|
191
|
+
assert work._event_queue is not None
|
|
192
|
+
work._event_queue.publish_after_commit(event)
|