Skip to content

Commit b5b7f4a

Browse files
committed
feat(events): add Laravel-style event dispatcher, Event facade, and event() helper
Add a basic async-aware event system to the core framework: - Dispatcher: listen (class/string/list events, decorator form), dispatch, until/halt, response collection, false-stops-propagation, has_listeners, forget, flush. Supports callable and class-based (handle) listeners, both sync and coroutine, with container dependency injection for listener classes. - EventServiceProvider: registered by default; subclass and set a listen map to wire events to listeners. - Event facade + .pyi stub and a global event() helper. - EventFake (Event.fake()) recording test double with assert_dispatched, assert_dispatched_times, assert_not_dispatched, assert_nothing_dispatched, and partial-fake passthrough to the real dispatcher. Full unit coverage for the events package.
1 parent 9f01c54 commit b5b7f4a

14 files changed

Lines changed: 766 additions & 0 deletions

File tree

‎fastapi_startkit/src/fastapi_startkit/application.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .config import AppConfig
99
from .configuration.providers import ConfigurationProvider
1010
from .container import Container
11+
from .events import EventServiceProvider
1112
from .environment.environment import Environment
1213

1314
if TYPE_CHECKING:
@@ -27,6 +28,7 @@ def app() -> "Container":
2728
class Application(Container, Generic[TConfig]):
2829
DEFAULT_PROVIDERS = [
2930
ConfigurationProvider,
31+
EventServiceProvider,
3032
AppProvider,
3133
]
3234

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from .dispatcher import Dispatcher
2+
from .fake import EventFake
3+
from .helpers import event
4+
from .listener import Listener
5+
from .provider import EventServiceProvider
6+
7+
__all__ = [
8+
"Dispatcher",
9+
"EventFake",
10+
"EventServiceProvider",
11+
"Listener",
12+
"event",
13+
]
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Application event dispatcher.
2+
3+
An async-aware, Laravel-style event dispatcher. Events are plain classes;
4+
listeners are callables or classes exposing a ``handle`` method. Both sync and
5+
coroutine listeners are supported — coroutine results are awaited transparently.
6+
"""
7+
8+
import inspect
9+
from typing import TYPE_CHECKING, Any, Callable, cast, overload
10+
11+
if TYPE_CHECKING:
12+
from ..container import Container
13+
from .fake import EventFake
14+
15+
16+
class Dispatcher:
17+
def __init__(self, container: "Container | None" = None):
18+
self._container = container
19+
self._listeners: dict[str, list] = {}
20+
21+
@overload
22+
def listen(self, events, listener: None = None) -> Callable: ...
23+
24+
@overload
25+
def listen(self, events, listener: Callable) -> None: ...
26+
27+
def listen(self, events, listener: Callable | None = None):
28+
"""Register a listener for one or more events.
29+
30+
``events`` may be an event class, a string name, or a list of either.
31+
Used as a decorator when ``listener`` is omitted.
32+
"""
33+
if listener is None:
34+
35+
def decorator(func: Callable) -> Callable:
36+
self.listen(events, func)
37+
return func
38+
39+
return decorator
40+
41+
for name in self._event_names(events):
42+
self._listeners.setdefault(name, []).append(listener)
43+
return None
44+
45+
def has_listeners(self, event) -> bool:
46+
return bool(self._listeners.get(self._event_key(event)))
47+
48+
def forget(self, event) -> None:
49+
"""Remove all listeners registered for a given event."""
50+
self._listeners.pop(self._event_key(event), None)
51+
52+
def flush(self) -> None:
53+
"""Remove every registered listener."""
54+
self._listeners.clear()
55+
56+
async def dispatch(self, event, payload=None, halt: bool = False):
57+
"""Fire an event and call its listeners in registration order.
58+
59+
Returns the list of listener responses. When ``halt`` is true, the first
60+
non-``None`` response is returned immediately. A listener returning
61+
``False`` stops propagation to later listeners.
62+
"""
63+
name, args = self._parse_event_payload(event, payload)
64+
65+
responses: list[Any] = []
66+
for listener in self._listeners.get(name, []):
67+
response = await self._call_listener(listener, args)
68+
69+
if halt and response is not None:
70+
return response
71+
72+
if response is False:
73+
break
74+
75+
responses.append(response)
76+
77+
return None if halt else responses
78+
79+
async def until(self, event, payload=None):
80+
"""Dispatch an event, returning the first non-``None`` listener response."""
81+
return await self.dispatch(event, payload, halt=True)
82+
83+
def fake(self, events_to_fake=None) -> "EventFake":
84+
"""Swap the container's dispatcher for a recording fake (testing helper)."""
85+
from .fake import EventFake
86+
87+
fake = EventFake(cast("Dispatcher", self), events_to_fake)
88+
if self._container is not None:
89+
self._container.bind("events", fake)
90+
return fake
91+
92+
def _event_names(self, events) -> list[str]:
93+
items = events if isinstance(events, (list, tuple)) else [events]
94+
return [self._event_key(event) for event in items]
95+
96+
def _event_key(self, event) -> str:
97+
if isinstance(event, str):
98+
return event
99+
cls = event if inspect.isclass(event) else type(event)
100+
return f"{cls.__module__}.{cls.__qualname__}"
101+
102+
def _parse_event_payload(self, event, payload) -> tuple[str, list]:
103+
if isinstance(event, str):
104+
if payload is None:
105+
args: list = []
106+
elif isinstance(payload, (list, tuple)):
107+
args = list(payload)
108+
else:
109+
args = [payload]
110+
return event, args
111+
112+
return self._event_key(event), [event]
113+
114+
async def _call_listener(self, listener: Callable, args: list):
115+
handler = self._resolve_listener(listener)
116+
result = handler(*args)
117+
if inspect.isawaitable(result):
118+
result = await result
119+
return result
120+
121+
def _resolve_listener(self, listener: Callable) -> Callable:
122+
if inspect.isclass(listener):
123+
return self._make(listener).handle
124+
return listener
125+
126+
def _make(self, cls):
127+
if self._container is not None:
128+
return self._container.resolve(cls)
129+
return cls()
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""A recording fake dispatcher used by ``Event.fake()`` in tests.
2+
3+
Faked events are captured instead of dispatched to their listeners; any event
4+
not in the fake list is forwarded to the real dispatcher. The recorded events
5+
can then be asserted against.
6+
"""
7+
8+
from typing import TYPE_CHECKING, Callable
9+
10+
if TYPE_CHECKING:
11+
from .dispatcher import Dispatcher
12+
13+
14+
class EventFake:
15+
def __init__(self, dispatcher: "Dispatcher", events_to_fake=None):
16+
self._dispatcher = dispatcher
17+
self._events_to_fake = self._normalize(events_to_fake)
18+
self._dispatched: dict[str, list] = {}
19+
20+
async def dispatch(self, event, payload=None, halt: bool = False):
21+
name, args = self._dispatcher._parse_event_payload(event, payload)
22+
23+
if self._should_fake(name):
24+
recorded = args if isinstance(event, str) else event
25+
self._dispatched.setdefault(name, []).append(recorded)
26+
return None if halt else []
27+
28+
return await self._dispatcher.dispatch(event, payload, halt)
29+
30+
async def until(self, event, payload=None):
31+
return await self.dispatch(event, payload, halt=True)
32+
33+
def listen(self, events, listener: Callable | None = None):
34+
return self._dispatcher.listen(events, listener)
35+
36+
def has_listeners(self, event) -> bool:
37+
return self._dispatcher.has_listeners(event)
38+
39+
def dispatched(self, event, callback: Callable | None = None) -> list:
40+
"""Return recorded dispatches for an event, optionally filtered."""
41+
records = self._dispatched.get(self._dispatcher._event_key(event), [])
42+
if callback is None:
43+
return list(records)
44+
return [record for record in records if callback(record)]
45+
46+
def assert_dispatched(self, event, callback: Callable | None = None) -> list:
47+
records = self.dispatched(event, callback)
48+
assert records, f"The expected event [{self._dispatcher._event_key(event)}] was not dispatched."
49+
return records
50+
51+
def assert_dispatched_times(self, event, times: int = 1) -> None:
52+
count = len(self.dispatched(event))
53+
assert count == times, (
54+
f"The expected event [{self._dispatcher._event_key(event)}] was dispatched {count} "
55+
f"time(s) instead of {times} time(s)."
56+
)
57+
58+
def assert_not_dispatched(self, event, callback: Callable | None = None) -> None:
59+
count = len(self.dispatched(event, callback))
60+
assert count == 0, f"The unexpected event [{self._dispatcher._event_key(event)}] was dispatched."
61+
62+
def assert_nothing_dispatched(self) -> None:
63+
total = sum(len(records) for records in self._dispatched.values())
64+
assert total == 0, f"{total} unexpected event(s) were dispatched."
65+
66+
def _normalize(self, events) -> list[str]:
67+
if events is None:
68+
return []
69+
items = events if isinstance(events, (list, tuple)) else [events]
70+
return [self._dispatcher._event_key(event) for event in items]
71+
72+
def _should_fake(self, name: str) -> bool:
73+
return not self._events_to_fake or name in self._events_to_fake
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Global ``event()`` helper mirroring Laravel's event helper."""
2+
3+
from typing import cast
4+
5+
from .dispatcher import Dispatcher
6+
7+
8+
def event(event, payload=None, halt: bool = False):
9+
"""Dispatch an event through the container's dispatcher.
10+
11+
Returns the awaitable produced by ``Dispatcher.dispatch`` — callers should
12+
``await`` it::
13+
14+
await event(OrderShipped(order))
15+
"""
16+
from ..application import app
17+
18+
dispatcher = cast(Dispatcher, app().make("events"))
19+
return dispatcher.dispatch(event, payload, halt)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Optional base class for class-based listeners.
2+
3+
Listeners are not required to extend this — any callable, or any class exposing
4+
a ``handle`` method, works. It exists to document the contract and to give
5+
type checkers something to lean on. ``handle`` may be sync or async.
6+
"""
7+
8+
from abc import ABC, abstractmethod
9+
from typing import Any
10+
11+
12+
class Listener(ABC):
13+
@abstractmethod
14+
def handle(self, event: Any):
15+
"""Handle the given event."""
16+
...
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Service provider that registers the event dispatcher.
2+
3+
Subclass and define ``listen`` to map events to their listeners, mirroring
4+
Laravel's ``EventServiceProvider``::
5+
6+
class AppEventServiceProvider(EventServiceProvider):
7+
listen = {
8+
UserRegistered: [SendWelcomeEmail, LogRegistration],
9+
}
10+
"""
11+
12+
from typing import cast
13+
14+
from fastapi_startkit.support import Provider
15+
16+
from .dispatcher import Dispatcher
17+
18+
19+
class EventServiceProvider(Provider):
20+
provider_key = "events"
21+
22+
listen: dict = {}
23+
24+
def register(self) -> None:
25+
self.app.bind("events", Dispatcher(self.app))
26+
27+
def boot(self) -> None:
28+
dispatcher = cast(Dispatcher, self.app.make("events"))
29+
for event, listeners in self.listen.items():
30+
for listener in listeners:
31+
dispatcher.listen(event, listener)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .Facade import Facade
2+
3+
4+
class Event(metaclass=Facade):
5+
key = "events"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import Any, Callable
2+
3+
from fastapi_startkit.events.fake import EventFake
4+
5+
class Event:
6+
"""Facade for the event dispatcher registered under the 'events' key."""
7+
8+
@staticmethod
9+
def listen(events: Any, listener: Callable | None = None) -> Callable | None:
10+
"""Register a listener for one or more events (or use as a decorator)."""
11+
...
12+
13+
@staticmethod
14+
async def dispatch(event: Any, payload: Any = None, halt: bool = False) -> Any:
15+
"""Fire an event and invoke its listeners; returns their responses."""
16+
...
17+
18+
@staticmethod
19+
async def until(event: Any, payload: Any = None) -> Any:
20+
"""Dispatch an event, returning the first non-None listener response."""
21+
...
22+
23+
@staticmethod
24+
def has_listeners(event: Any) -> bool:
25+
"""Return whether any listener is registered for the event."""
26+
...
27+
28+
@staticmethod
29+
def forget(event: Any) -> None:
30+
"""Remove all listeners registered for the event."""
31+
...
32+
33+
@staticmethod
34+
def flush() -> None:
35+
"""Remove every registered listener."""
36+
...
37+
38+
@staticmethod
39+
def fake(events_to_fake: Any = None) -> EventFake:
40+
"""Swap the dispatcher for a recording fake and return it."""
41+
...

‎fastapi_startkit/src/fastapi_startkit/facades/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .View import View
99
from .Gate import Gate
1010
from .Config import Config
11+
from .Event import Event
1112
from .Loader import Loader
1213
from .Notification import Notification
1314
from .Dump import Dump

0 commit comments

Comments
 (0)