Skip to content

Commit 976b3ba

Browse files
vadikko2Вадим Козыревский
andauthored
[Feature] Add event propagation into event handlers (#58)
* Add event propagation into event handlers * Fix pyproject.toml * Fix tests.yaml * Fixes after review * Fixes after review * Add ignore codspeed file * Add ignore codspeed file * Fix nitpicks after review * Added new tests for coverage increase * Fixes after pre-commit --------- Co-authored-by: Вадим Козыревский <v.kozyrevskiy@timeweb.ru>
1 parent 245d268 commit 976b3ba

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2735
-2576
lines changed

.github/workflows/python-publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
runs-on: ubuntu-latest
1414
strategy:
1515
matrix:
16-
python-version: [ "3.12" ]
16+
python-version: [ "3.10", "3.11", "3.12" ]
1717

1818
steps:
1919
- uses: actions/checkout@v4
@@ -28,6 +28,6 @@ jobs:
2828
- name: Build package
2929
run: python -m build
3030
- name: Publish package
31-
if: success() && github.event_name == 'release'
31+
if: success() && github.event_name == 'release' && matrix.python-version == '3.12'
3232
run: |
3333
twine upload dist/* --username __token__ --password ${{ secrets.PYPI_API_TOKEN }}

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,15 @@ jobs:
5050
echo "No Python files changed, skipping ruff check"
5151
exit 0
5252
fi
53-
cat changed.txt | xargs -r ruff check --config ruff.toml
53+
while IFS= read -r f; do [ -f "$f" ] && echo "$f"; done < changed.txt | xargs -r ruff check --config ruff.toml
5454
5555
- name: Run ruff format check
5656
run: |
5757
if [ "${{ steps.changed.outputs.has_changes }}" != "true" ]; then
5858
echo "No Python files changed, skipping ruff format"
5959
exit 0
6060
fi
61-
cat changed.txt | xargs -r ruff format --check --config ruff.toml
61+
while IFS= read -r f; do [ -f "$f" ] && echo "$f"; done < changed.txt | xargs -r ruff format --check --config ruff.toml
6262
6363
- name: Run pyright
6464
run: |

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,5 @@ tmp/
167167

168168
# UV lock
169169
uv.lock
170+
171+
.codspeed/

pyproject.toml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,21 @@ classifiers = [
1717
]
1818
dependencies = [
1919
"dataclass-wizard==0.*",
20-
"di[anyio]==0.79.2",
21-
"dependency-injector>=4.48.2",
20+
"di[anyio]==0.*",
21+
"dependency-injector>=4.0",
2222
"orjson==3.*",
2323
"pydantic==2.*",
24-
"python-dotenv==1.0.1",
25-
"retry-async==0.1.4",
24+
"python-dotenv==1.*",
25+
"retry-async==0.1.*",
2626
"sqlalchemy[asyncio]==2.0.*",
27-
"typing-extensions>=4.0.0"
27+
"typing-extensions>=4.0"
2828
]
29-
description = "Python CQRS pattern implementation"
29+
description = "Event-Driven Architecture Framework for Distributed Systems"
3030
maintainers = [{name = "Vadim Kozyrevskiy", email = "vadikko2@mail.ru"}]
3131
name = "python-cqrs"
3232
readme = "README.md"
3333
requires-python = ">=3.10"
34-
version = "4.7.3"
34+
version = "4.8.0"
3535

3636
[project.optional-dependencies]
3737
aiobreaker = ["aiobreaker>=0.3.0"]
@@ -62,10 +62,10 @@ examples = [
6262
"faststream[kafka]==0.5.28",
6363
"faker>=37.12.0",
6464
"uvicorn==0.32.0",
65-
"aiohttp==3.13.2"
65+
"aiohttp==3.13.2",
66+
"protobuf>=4.25.8",
6667
]
6768
kafka = ["aiokafka==0.10.0"]
68-
protobuf = ["protobuf==4.25.5"]
6969
rabbit = ["aio-pika==9.3.0"]
7070

7171
[project.urls]

src/cqrs/dispatcher/event.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ async def _handle_event(
2727
self,
2828
event: IEvent,
2929
handle_type: typing.Type[_EventHandler],
30-
):
30+
) -> None:
3131
handler: _EventHandler = await self._container.resolve(handle_type)
3232
await handler.handle(event)
33+
for follow_up in handler.events:
34+
await self.dispatch(follow_up)
3335

3436
async def dispatch(self, event: IEvent) -> None:
3537
handler_types = self._event_map.get(type(event), [])
@@ -38,5 +40,6 @@ async def dispatch(self, event: IEvent) -> None:
3840
"Handlers for event %s not found",
3941
type(event).__name__,
4042
)
43+
return
4144
for h_type in handler_types:
4245
await self._handle_event(event, h_type)

src/cqrs/events/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
113
from cqrs.events.event import (
214
DCEvent,
315
DCDomainEvent,

src/cqrs/events/bootstrap.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,31 @@ def setup_mediator(
3131
middlewares: typing.Iterable[mediator_middlewares.Middleware],
3232
events_mapper: typing.Callable[[events.EventMap], None] | None = None,
3333
) -> 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+
"""
3459
_events_mapper = events.EventMap()
3560
if events_mapper is not None:
3661
events_mapper(_events_mapper)
@@ -71,6 +96,34 @@ def bootstrap(
7196
events_mapper: typing.Callable[[events.EventMap], None] | None = None,
7297
on_startup: typing.List[typing.Callable[[], None]] | None = None,
7398
) -> 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+
"""
74127
if on_startup is None:
75128
on_startup = []
76129

@@ -90,8 +143,10 @@ def bootstrap(
90143
middlewares_list: typing.List[mediator_middlewares.Middleware] = list(
91144
middlewares or [],
92145
)
146+
if not any(isinstance(m, logging_middleware.LoggingMiddleware) for m in middlewares_list):
147+
middlewares_list.append(logging_middleware.LoggingMiddleware())
93148
return setup_mediator(
94149
container,
95150
events_mapper=events_mapper,
96-
middlewares=middlewares_list + [logging_middleware.LoggingMiddleware()],
151+
middlewares=middlewares_list,
97152
)

src/cqrs/events/event.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import abc
22
import dataclasses
3-
from dataclass_wizard import fromdict, asdict
43
import datetime
54
import os
5+
import sys
66
import typing
77
import uuid
8-
import sys
98

109
import dotenv
1110
import pydantic
11+
from dataclass_wizard import asdict, fromdict
1212

1313
if sys.version_info >= (3, 11):
1414
from typing import Self # novm
@@ -251,6 +251,9 @@ class INotificationEvent(IEvent, typing.Generic[PayloadT]):
251251

252252
def proto(self) -> typing.Any: ... # Method for protobuf representation
253253

254+
@classmethod
255+
def from_proto(cls, proto: typing.Any) -> Self: ...
256+
254257

255258
@dataclasses.dataclass(frozen=True)
256259
class DCNotificationEvent(
@@ -300,7 +303,18 @@ def proto(self) -> typing.Any:
300303
NotImplementedError: This method must be implemented by subclasses
301304
that need protobuf serialization.
302305
"""
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")
304318

305319
def __hash__(self) -> int:
306320
"""
@@ -345,10 +359,34 @@ class UserRegisteredEvent(PydanticNotificationEvent[dict]):
345359

346360
model_config = pydantic.ConfigDict(from_attributes=True)
347361

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+
"""
349370
raise NotImplementedError("Method not implemented")
350371

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+
"""
352390
return hash(self.event_id)
353391

354392

0 commit comments

Comments
 (0)