python-cqrs 4.7.2__tar.gz → 4.8.0__tar.gz

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 (88) hide show
  1. {python_cqrs-4.7.2/src/python_cqrs.egg-info → python_cqrs-4.8.0}/PKG-INFO +8 -9
  2. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/pyproject.toml +9 -12
  3. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/dispatcher/event.py +4 -1
  4. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/dispatcher/saga.py +9 -1
  5. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/dispatcher/streaming.py +9 -4
  6. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/events/__init__.py +12 -0
  7. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/events/bootstrap.py +56 -1
  8. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/events/event.py +43 -5
  9. python_cqrs-4.8.0/src/cqrs/events/event_emitter.py +142 -0
  10. python_cqrs-4.8.0/src/cqrs/events/event_handler.py +59 -0
  11. python_cqrs-4.8.0/src/cqrs/events/event_processor.py +143 -0
  12. python_cqrs-4.8.0/src/cqrs/events/map.py +68 -0
  13. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/mediator.py +18 -5
  14. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/requests/bootstrap.py +6 -2
  15. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/requests/cor_request_handler.py +7 -2
  16. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/requests/request_handler.py +21 -5
  17. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/bootstrap.py +4 -2
  18. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/step.py +4 -3
  19. {python_cqrs-4.7.2 → python_cqrs-4.8.0/src/python_cqrs.egg-info}/PKG-INFO +8 -9
  20. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/python_cqrs.egg-info/requires.txt +6 -8
  21. python_cqrs-4.7.2/src/cqrs/events/event_emitter.py +0 -82
  22. python_cqrs-4.7.2/src/cqrs/events/event_handler.py +0 -26
  23. python_cqrs-4.7.2/src/cqrs/events/event_processor.py +0 -66
  24. python_cqrs-4.7.2/src/cqrs/events/map.py +0 -29
  25. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/LICENSE +0 -0
  26. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/README.md +0 -0
  27. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/setup.cfg +0 -0
  28. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/__init__.py +0 -0
  29. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/adapters/__init__.py +0 -0
  30. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/adapters/amqp.py +0 -0
  31. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/adapters/circuit_breaker.py +0 -0
  32. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/adapters/kafka.py +0 -0
  33. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/adapters/protocol.py +0 -0
  34. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/compressors/__init__.py +0 -0
  35. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/compressors/protocol.py +0 -0
  36. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/compressors/zlib.py +0 -0
  37. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/container/__init__.py +0 -0
  38. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/container/dependency_injector.py +0 -0
  39. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/container/di.py +0 -0
  40. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/container/protocol.py +0 -0
  41. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/deserializers/__init__.py +0 -0
  42. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/deserializers/exceptions.py +0 -0
  43. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/deserializers/json.py +0 -0
  44. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/dispatcher/__init__.py +0 -0
  45. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/dispatcher/exceptions.py +0 -0
  46. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/dispatcher/models.py +0 -0
  47. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/dispatcher/request.py +0 -0
  48. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/message_brokers/__init__.py +0 -0
  49. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/message_brokers/amqp.py +0 -0
  50. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/message_brokers/devnull.py +0 -0
  51. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/message_brokers/kafka.py +0 -0
  52. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/message_brokers/protocol.py +0 -0
  53. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/middlewares/__init__.py +0 -0
  54. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/middlewares/base.py +0 -0
  55. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/middlewares/logging.py +0 -0
  56. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/outbox/__init__.py +0 -0
  57. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/outbox/map.py +0 -0
  58. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/outbox/mock.py +0 -0
  59. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/outbox/repository.py +0 -0
  60. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/outbox/sqlalchemy.py +0 -0
  61. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/producer.py +0 -0
  62. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/requests/__init__.py +0 -0
  63. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/requests/map.py +0 -0
  64. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/requests/mermaid.py +0 -0
  65. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/requests/request.py +0 -0
  66. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/response.py +0 -0
  67. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/__init__.py +0 -0
  68. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/circuit_breaker.py +0 -0
  69. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/compensation.py +0 -0
  70. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/execution.py +0 -0
  71. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/fallback.py +0 -0
  72. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/mermaid.py +0 -0
  73. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/models.py +0 -0
  74. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/recovery.py +0 -0
  75. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/saga.py +0 -0
  76. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/storage/__init__.py +0 -0
  77. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/storage/enums.py +0 -0
  78. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/storage/memory.py +0 -0
  79. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/storage/models.py +0 -0
  80. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/storage/protocol.py +0 -0
  81. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/storage/sqlalchemy.py +0 -0
  82. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/saga/validation.py +0 -0
  83. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/serializers/__init__.py +0 -0
  84. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/serializers/default.py +0 -0
  85. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/cqrs/types.py +0 -0
  86. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/python_cqrs.egg-info/SOURCES.txt +0 -0
  87. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
  88. {python_cqrs-4.7.2 → python_cqrs-4.8.0}/src/python_cqrs.egg-info/top_level.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-cqrs
3
- Version: 4.7.2
4
- Summary: Python CQRS pattern implementation
3
+ Version: 4.8.0
4
+ Summary: Event-Driven Architecture Framework for Distributed Systems
5
5
  Author-email: Vadim Kozyrevskiy <vadikko2@mail.ru>, Dmitry Kutlubaev <kutlubaev00@mail.ru>
6
6
  Maintainer-email: Vadim Kozyrevskiy <vadikko2@mail.ru>
7
7
  Project-URL: Documentation, https://mkdocs.python-cqrs.dev/
@@ -17,14 +17,14 @@ Requires-Python: >=3.10
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: dataclass-wizard==0.*
20
- Requires-Dist: di[anyio]==0.79.2
21
- Requires-Dist: dependency-injector>=4.48.2
20
+ Requires-Dist: di[anyio]==0.*
21
+ Requires-Dist: dependency-injector>=4.0
22
22
  Requires-Dist: orjson==3.*
23
23
  Requires-Dist: pydantic==2.*
24
- Requires-Dist: python-dotenv==1.0.1
25
- Requires-Dist: retry-async==0.1.4
24
+ Requires-Dist: python-dotenv==1.*
25
+ Requires-Dist: retry-async==0.1.*
26
26
  Requires-Dist: sqlalchemy[asyncio]==2.0.*
27
- Requires-Dist: typing-extensions>=4.0.0
27
+ Requires-Dist: typing-extensions>=4.0
28
28
  Provides-Extra: aiobreaker
29
29
  Requires-Dist: aiobreaker>=0.3.0; extra == "aiobreaker"
30
30
  Provides-Extra: dev
@@ -51,10 +51,9 @@ Requires-Dist: faststream[kafka]==0.5.28; extra == "examples"
51
51
  Requires-Dist: faker>=37.12.0; extra == "examples"
52
52
  Requires-Dist: uvicorn==0.32.0; extra == "examples"
53
53
  Requires-Dist: aiohttp==3.13.2; extra == "examples"
54
+ Requires-Dist: protobuf>=4.25.8; extra == "examples"
54
55
  Provides-Extra: kafka
55
56
  Requires-Dist: aiokafka==0.10.0; extra == "kafka"
56
- Provides-Extra: protobuf
57
- Requires-Dist: protobuf==4.25.5; extra == "protobuf"
58
57
  Provides-Extra: rabbit
59
58
  Requires-Dist: aio-pika==9.3.0; extra == "rabbit"
60
59
  Dynamic: license-file
@@ -17,21 +17,21 @@ classifiers = [
17
17
  ]
18
18
  dependencies = [
19
19
  "dataclass-wizard==0.*",
20
- "di[anyio]==0.79.2",
21
- "dependency-injector>=4.48.2",
20
+ "di[anyio]==0.*",
21
+ "dependency-injector>=4.0",
22
22
  "orjson==3.*",
23
23
  "pydantic==2.*",
24
- "python-dotenv==1.0.1",
25
- "retry-async==0.1.4",
24
+ "python-dotenv==1.*",
25
+ "retry-async==0.1.*",
26
26
  "sqlalchemy[asyncio]==2.0.*",
27
- "typing-extensions>=4.0.0"
27
+ "typing-extensions>=4.0"
28
28
  ]
29
- description = "Python CQRS pattern implementation"
29
+ description = "Event-Driven Architecture Framework for Distributed Systems"
30
30
  maintainers = [{name = "Vadim Kozyrevskiy", email = "vadikko2@mail.ru"}]
31
31
  name = "python-cqrs"
32
32
  readme = "README.md"
33
33
  requires-python = ">=3.10"
34
- version = "4.7.2"
34
+ version = "4.8.0"
35
35
 
36
36
  [project.optional-dependencies]
37
37
  aiobreaker = ["aiobreaker>=0.3.0"]
@@ -62,10 +62,10 @@ examples = [
62
62
  "faststream[kafka]==0.5.28",
63
63
  "faker>=37.12.0",
64
64
  "uvicorn==0.32.0",
65
- "aiohttp==3.13.2"
65
+ "aiohttp==3.13.2",
66
+ "protobuf>=4.25.8",
66
67
  ]
67
68
  kafka = ["aiokafka==0.10.0"]
68
- protobuf = ["protobuf==4.25.5"]
69
69
  rabbit = ["aio-pika==9.3.0"]
70
70
 
71
71
  [project.urls]
@@ -79,8 +79,5 @@ asyncio_mode = "auto"
79
79
  junit_family = "xunit1"
80
80
  testpaths = ["tests"]
81
81
 
82
- [tool.ruff]
83
- target-version = "py310"
84
-
85
82
  [tool.setuptools.packages.find]
86
83
  where = ["src"]
@@ -27,9 +27,11 @@ class EventDispatcher:
27
27
  self,
28
28
  event: IEvent,
29
29
  handle_type: typing.Type[_EventHandler],
30
- ):
30
+ ) -> None:
31
31
  handler: _EventHandler = await self._container.resolve(handle_type)
32
32
  await handler.handle(event)
33
+ for follow_up in handler.events:
34
+ await self.dispatch(follow_up)
33
35
 
34
36
  async def dispatch(self, event: IEvent) -> None:
35
37
  handler_types = self._event_map.get(type(event), [])
@@ -38,5 +40,6 @@ class EventDispatcher:
38
40
  "Handlers for event %s not found",
39
41
  type(event).__name__,
40
42
  )
43
+ return
41
44
  for h_type in handler_types:
42
45
  await self._handle_event(event, h_type)
@@ -53,7 +53,7 @@ class SagaDispatcher:
53
53
  self._compensation_retry_delay = compensation_retry_delay
54
54
  self._compensation_retry_backoff = compensation_retry_backoff
55
55
 
56
- async def dispatch(
56
+ def dispatch(
57
57
  self,
58
58
  context: SagaContext,
59
59
  saga_id: uuid.UUID | None = None,
@@ -61,6 +61,7 @@ class SagaDispatcher:
61
61
  """
62
62
  Dispatch a saga execution for the given context.
63
63
 
64
+ Called without await; returns an AsyncIterator consumed with async for.
64
65
  Yields result after each step execution. After each yield, events are collected
65
66
  and included in the dispatch result.
66
67
 
@@ -75,6 +76,13 @@ class SagaDispatcher:
75
76
  Raises:
76
77
  SagaDoesNotExist: If no saga is registered for the context type
77
78
  """
79
+ return self._dispatch_impl(context, saga_id=saga_id)
80
+
81
+ async def _dispatch_impl(
82
+ self,
83
+ context: SagaContext,
84
+ saga_id: uuid.UUID | None = None,
85
+ ) -> typing.AsyncIterator[SagaDispatchResult]:
78
86
  # Find saga type by context type
79
87
  saga_type = self._saga_map.get(type(context))
80
88
  if not saga_type:
@@ -28,16 +28,23 @@ class StreamingRequestDispatcher:
28
28
  self._container = container
29
29
  self._middleware_chain = middleware_chain or MiddlewareChain()
30
30
 
31
- async def dispatch(
31
+ def dispatch(
32
32
  self,
33
33
  request: IRequest,
34
34
  ) -> typing.AsyncIterator[RequestDispatchResult]:
35
35
  """
36
36
  Dispatch a request to a streaming handler and yield results.
37
37
 
38
+ Called without await; returns an AsyncIterator consumed with async for.
38
39
  After each yield from the handler, events are collected and included
39
40
  in the dispatch result. The generator continues until StopIteration.
40
41
  """
42
+ return self._dispatch_impl(request)
43
+
44
+ async def _dispatch_impl(
45
+ self,
46
+ request: IRequest,
47
+ ) -> typing.AsyncIterator[RequestDispatchResult]:
41
48
  handler_type = self._request_map.get(type(request), None)
42
49
  if handler_type is None:
43
50
  raise RequestHandlerDoesNotExist(
@@ -62,9 +69,7 @@ class StreamingRequestDispatcher:
62
69
 
63
70
  if not inspect.isasyncgenfunction(handler.handle):
64
71
  handler_name = (
65
- handler_type_typed.__name__
66
- if hasattr(handler_type_typed, "__name__")
67
- else str(handler_type_typed)
72
+ handler_type_typed.__name__ if hasattr(handler_type_typed, "__name__") else str(handler_type_typed)
68
73
  )
69
74
  raise TypeError(
70
75
  f"Handler {handler_name}.handle must be an async generator function",
@@ -1,3 +1,15 @@
1
+ """Event types, handlers, emitter, and event map for the CQRS events layer.
2
+
3
+ Public API:
4
+ - Event types: :class:`Event`, :class:`DomainEvent`, :class:`NotificationEvent`,
5
+ and their interfaces/base classes.
6
+ - :class:`EventHandler` — handler interface; implement :meth:`EventHandler.handle`
7
+ and optionally :attr:`EventHandler.events` for follow-up events.
8
+ - :class:`EventEmitter` — sends domain events to handlers and notification events
9
+ to a message broker.
10
+ - :class:`EventMap` — registry of event type -> handler types; use :meth:`EventMap.bind`.
11
+ """
12
+
1
13
  from cqrs.events.event import (
2
14
  DCEvent,
3
15
  DCDomainEvent,
@@ -31,6 +31,31 @@ def setup_mediator(
31
31
  middlewares: typing.Iterable[mediator_middlewares.Middleware],
32
32
  events_mapper: typing.Callable[[events.EventMap], None] | None = None,
33
33
  ) -> cqrs.EventMediator:
34
+ """
35
+ Create an event mediator with the given container and middlewares.
36
+
37
+ Args:
38
+ container: DI container (e.g. :class:`cqrs.container.di.DIContainer`) or
39
+ any implementation of :class:`cqrs.container.protocol.Container`.
40
+ middlewares: Middleware chain for the mediator (e.g. logging).
41
+ events_mapper: Optional callable that receives an :class:`~cqrs.events.map.EventMap`
42
+ and binds event types to handler types via :meth:`~cqrs.events.map.EventMap.bind`.
43
+
44
+ Returns:
45
+ Configured :class:`cqrs.EventMediator` instance.
46
+
47
+ Example::
48
+
49
+ def bind_events(event_map: events.EventMap) -> None:
50
+ event_map.bind(OrderCreatedEvent, OrderCreatedEventHandler)
51
+
52
+ mediator = setup_mediator(
53
+ container=di_container,
54
+ middlewares=[logging_middleware.LoggingMiddleware()],
55
+ events_mapper=bind_events,
56
+ )
57
+ await mediator.emit(OrderCreatedEvent(order_id="1"))
58
+ """
34
59
  _events_mapper = events.EventMap()
35
60
  if events_mapper is not None:
36
61
  events_mapper(_events_mapper)
@@ -71,6 +96,34 @@ def bootstrap(
71
96
  events_mapper: typing.Callable[[events.EventMap], None] | None = None,
72
97
  on_startup: typing.List[typing.Callable[[], None]] | None = None,
73
98
  ) -> cqrs.EventMediator:
99
+ """
100
+ Bootstrap an event mediator with optional middlewares and event bindings.
101
+
102
+ If ``di_container`` is a :class:`di.Container`, it is wrapped in
103
+ :class:`cqrs.container.di.DIContainer`. Logging middleware is appended
104
+ to the middleware list. Runs all ``on_startup`` callables before setup.
105
+
106
+ Args:
107
+ di_container: DI container from the ``di`` package or a CQRS container.
108
+ middlewares: Optional list of middlewares (e.g. logging, metrics).
109
+ events_mapper: Optional callable that receives an :class:`~cqrs.events.map.EventMap`
110
+ and binds event types to handler types.
111
+ on_startup: Optional list of callables to run before creating the mediator.
112
+
113
+ Returns:
114
+ Configured :class:`cqrs.EventMediator` with logging middleware enabled.
115
+
116
+ Example::
117
+
118
+ def bind_events(event_map: events.EventMap) -> None:
119
+ event_map.bind(OrderCreatedEvent, OrderCreatedEventHandler)
120
+
121
+ mediator = bootstrap(
122
+ di_container=di.Container(),
123
+ events_mapper=bind_events,
124
+ )
125
+ await mediator.emit(OrderCreatedEvent(order_id="1"))
126
+ """
74
127
  if on_startup is None:
75
128
  on_startup = []
76
129
 
@@ -90,8 +143,10 @@ def bootstrap(
90
143
  middlewares_list: typing.List[mediator_middlewares.Middleware] = list(
91
144
  middlewares or [],
92
145
  )
146
+ if not any(isinstance(m, logging_middleware.LoggingMiddleware) for m in middlewares_list):
147
+ middlewares_list.append(logging_middleware.LoggingMiddleware())
93
148
  return setup_mediator(
94
149
  container,
95
150
  events_mapper=events_mapper,
96
- middlewares=middlewares_list + [logging_middleware.LoggingMiddleware()],
151
+ middlewares=middlewares_list,
97
152
  )
@@ -1,14 +1,14 @@
1
1
  import abc
2
2
  import dataclasses
3
- from dataclass_wizard import fromdict, asdict
4
3
  import datetime
5
4
  import os
5
+ import sys
6
6
  import typing
7
7
  import uuid
8
- import sys
9
8
 
10
9
  import dotenv
11
10
  import pydantic
11
+ from dataclass_wizard import asdict, fromdict
12
12
 
13
13
  if sys.version_info >= (3, 11):
14
14
  from typing import Self # novm
@@ -251,6 +251,9 @@ class INotificationEvent(IEvent, typing.Generic[PayloadT]):
251
251
 
252
252
  def proto(self) -> typing.Any: ... # Method for protobuf representation
253
253
 
254
+ @classmethod
255
+ def from_proto(cls, proto: typing.Any) -> Self: ...
256
+
254
257
 
255
258
  @dataclasses.dataclass(frozen=True)
256
259
  class DCNotificationEvent(
@@ -300,7 +303,18 @@ class DCNotificationEvent(
300
303
  NotImplementedError: This method must be implemented by subclasses
301
304
  that need protobuf serialization.
302
305
  """
303
- raise NotImplementedError("Method not implemented for dataclass events")
306
+ raise NotImplementedError("Method not implemented")
307
+
308
+ @classmethod
309
+ def from_proto(cls, proto: typing.Any) -> Self:
310
+ """
311
+ Constructs event from proto event object
312
+
313
+ Raises:
314
+ NotImplementedError: This method must be implemented by subclasses
315
+ that need protobuf deserialization.
316
+ """
317
+ raise NotImplementedError("Method not implemented")
304
318
 
305
319
  def __hash__(self) -> int:
306
320
  """
@@ -345,10 +359,34 @@ class PydanticNotificationEvent(
345
359
 
346
360
  model_config = pydantic.ConfigDict(from_attributes=True)
347
361
 
348
- def proto(self):
362
+ def proto(self) -> typing.Any:
363
+ """
364
+ Return protobuf representation of the event.
365
+
366
+ Raises:
367
+ NotImplementedError: This method must be implemented by subclasses
368
+ that need protobuf serialization.
369
+ """
349
370
  raise NotImplementedError("Method not implemented")
350
371
 
351
- def __hash__(self):
372
+ @classmethod
373
+ def from_proto(cls, proto: typing.Any) -> Self:
374
+ """
375
+ Constructs event from proto event object
376
+
377
+ Raises:
378
+ NotImplementedError: This method must be implemented by subclasses
379
+ that need protobuf deserialization.
380
+ """
381
+ raise NotImplementedError("Method not implemented")
382
+
383
+ def __hash__(self) -> int:
384
+ """
385
+ Return the hash of the event based on its event_id.
386
+
387
+ Returns:
388
+ Hash value of the event_id.
389
+ """
352
390
  return hash(self.event_id)
353
391
 
354
392
 
@@ -0,0 +1,142 @@
1
+ import functools
2
+ import logging
3
+ import typing
4
+
5
+ from cqrs import container as di_container, message_brokers
6
+ from cqrs.events.event import IDomainEvent, IEvent, INotificationEvent
7
+ from cqrs.events import event_handler, map
8
+
9
+ logger = logging.getLogger("cqrs")
10
+
11
+ _H: typing.TypeAlias = event_handler.EventHandler
12
+
13
+
14
+ class EventEmitter:
15
+ """
16
+ Sends events to registered handlers or to a message broker.
17
+
18
+ For :class:`~cqrs.events.event.IDomainEvent`: resolves handlers from the
19
+ container, runs :meth:`~cqrs.events.event_handler.EventHandler.handle`, and
20
+ returns follow-up events from :attr:`~cqrs.events.event_handler.EventHandler.events`.
21
+ For :class:`~cqrs.events.event.INotificationEvent`: sends the event to the
22
+ message broker (if configured) and returns an empty sequence.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ event_map: map.EventMap,
28
+ container: di_container.Container,
29
+ message_broker: message_brokers.MessageBroker | None = None,
30
+ ) -> None:
31
+ """
32
+ Initialize the event emitter.
33
+
34
+ Args:
35
+ event_map: Map of event types to handler types (used for domain events).
36
+ container: DI container to resolve handler instances.
37
+ message_broker: Optional broker for notification events; required
38
+ when emitting :class:`~cqrs.events.event.INotificationEvent`.
39
+
40
+ Example::
41
+
42
+ event_map = EventMap()
43
+ event_map.bind(OrderCreatedEvent, OrderCreatedEventHandler)
44
+ emitter = EventEmitter(
45
+ event_map=event_map,
46
+ container=di_container,
47
+ message_broker=kafka_broker,
48
+ )
49
+ follow_ups = await emitter.emit(OrderCreatedEvent(order_id="1"))
50
+ """
51
+ self._event_map = event_map
52
+ self._container = container
53
+ self._message_broker = message_broker
54
+
55
+ @functools.singledispatchmethod
56
+ async def emit(self, event: IEvent) -> typing.Sequence[IEvent]:
57
+ """
58
+ Emit an event and return follow-up events from handlers.
59
+
60
+ For unknown event types returns an empty sequence. For domain events
61
+ invokes all registered handlers and collects events from
62
+ :attr:`~cqrs.events.event_handler.EventHandler.events`. For notification
63
+ events sends to the message broker.
64
+
65
+ Args:
66
+ event: The event to emit (domain or notification).
67
+
68
+ Returns:
69
+ Follow-up events returned by domain event handlers; empty for
70
+ notification events or when no handlers are registered.
71
+
72
+ Example::
73
+
74
+ follow_ups = await emitter.emit(OrderCreatedEvent(order_id="1"))
75
+ for e in follow_ups:
76
+ await emitter.emit(e) # or process via EventProcessor
77
+ """
78
+ return ()
79
+
80
+ async def _send_to_broker(
81
+ self,
82
+ event: INotificationEvent,
83
+ ) -> None:
84
+ """
85
+ Send a notification event to the message broker.
86
+
87
+ Args:
88
+ event: Notification event to send.
89
+
90
+ Raises:
91
+ RuntimeError: If no message broker was configured.
92
+ """
93
+ if not self._message_broker:
94
+ raise RuntimeError(
95
+ f"To send event {event}, message broker must be specified.",
96
+ )
97
+
98
+ message = message_brokers.Message(
99
+ message_name=type(event).__name__,
100
+ message_id=event.event_id,
101
+ topic=event.topic,
102
+ payload=event.to_dict(),
103
+ )
104
+
105
+ logger.debug(
106
+ "Sending Event(%s) to message broker %s",
107
+ event.event_id,
108
+ type(self._message_broker).__name__,
109
+ )
110
+
111
+ await self._message_broker.send_message(message)
112
+
113
+ @emit.register(IDomainEvent)
114
+ async def _(self, event: IDomainEvent) -> typing.Sequence[IEvent]:
115
+ """Emit domain event: run all registered handlers and return their follow-up events."""
116
+ handlers_types = self._event_map.get(type(event), [])
117
+ if not handlers_types:
118
+ logger.warning(
119
+ "Handlers for domain event %s not found",
120
+ type(event).__name__,
121
+ )
122
+ return ()
123
+ follow_ups: list[IEvent] = []
124
+ for handler_type in handlers_types:
125
+ handler: _H = await self._container.resolve(
126
+ handler_type,
127
+ )
128
+ logger.debug(
129
+ "Handling Event(%s) via event handler(%s)",
130
+ type(event).__name__,
131
+ handler_type.__name__,
132
+ )
133
+ await handler.handle(event)
134
+ # Snapshot follow-ups so shared handlers don't expose stale state
135
+ follow_ups.extend(list(handler.events))
136
+ return follow_ups
137
+
138
+ @emit.register(INotificationEvent)
139
+ async def _(self, event: INotificationEvent) -> typing.Sequence[IEvent]:
140
+ """Emit notification event: send to message broker; no follow-ups."""
141
+ await self._send_to_broker(event)
142
+ return ()
@@ -0,0 +1,59 @@
1
+ import abc
2
+ from collections.abc import Sequence
3
+ import typing
4
+
5
+ from cqrs.events.event import IEvent
6
+
7
+ E = typing.TypeVar("E", bound=IEvent, contravariant=True)
8
+
9
+
10
+ class EventHandler(abc.ABC, typing.Generic[E]):
11
+ """
12
+ The event handler interface.
13
+
14
+ Subclasses must implement :meth:`handle`. Optionally override :attr:`events`
15
+ to return follow-up events emitted after handling (e.g. for multi-level
16
+ event chains).
17
+
18
+ Example::
19
+
20
+ class UserJoinedEvent(DomainEvent):
21
+ meeting_id: str
22
+ user_id: str
23
+
24
+ class UserJoinedEventHandler(EventHandler[UserJoinedEvent]):
25
+ def __init__(self, meetings_api: MeetingAPIProtocol) -> None:
26
+ self._meetings_api = meetings_api
27
+
28
+ async def handle(self, event: UserJoinedEvent) -> None:
29
+ await self._meetings_api.notify_room(
30
+ event.meeting_id, "New user joined!"
31
+ )
32
+ """
33
+
34
+ @property
35
+ def events(self) -> Sequence[IEvent]:
36
+ """
37
+ Events produced by this handler after :meth:`handle` was called.
38
+
39
+ Override in subclasses to return follow-up events that should be
40
+ processed by the same pipeline (e.g. domain events to emit). By default
41
+ returns an empty sequence.
42
+
43
+ Returns:
44
+ Sequence of follow-up events (e.g. new domain events) to process.
45
+ """
46
+ return ()
47
+
48
+ @abc.abstractmethod
49
+ async def handle(self, event: E) -> None:
50
+ """
51
+ Handle the given event.
52
+
53
+ Args:
54
+ event: The event instance to handle.
55
+
56
+ Raises:
57
+ NotImplementedError: Must be implemented by subclasses.
58
+ """
59
+ raise NotImplementedError