From b5b7f4adba8b7c396f860516034f239d9fd2ba12 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 13 Jul 2026 01:18:58 -0700 Subject: [PATCH 1/3] 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. --- .../src/fastapi_startkit/application.py | 2 + .../src/fastapi_startkit/events/__init__.py | 13 + .../src/fastapi_startkit/events/dispatcher.py | 129 ++++++++++ .../src/fastapi_startkit/events/fake.py | 73 ++++++ .../src/fastapi_startkit/events/helpers.py | 19 ++ .../src/fastapi_startkit/events/listener.py | 16 ++ .../src/fastapi_startkit/events/provider.py | 31 +++ .../src/fastapi_startkit/facades/Event.py | 5 + .../src/fastapi_startkit/facades/Event.pyi | 41 ++++ .../src/fastapi_startkit/facades/__init__.py | 1 + fastapi_startkit/tests/events/__init__.py | 0 .../tests/events/test_dispatcher.py | 228 ++++++++++++++++++ fastapi_startkit/tests/events/test_fake.py | 127 ++++++++++ .../tests/events/test_provider.py | 81 +++++++ 14 files changed, 766 insertions(+) create mode 100644 fastapi_startkit/src/fastapi_startkit/events/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/events/dispatcher.py create mode 100644 fastapi_startkit/src/fastapi_startkit/events/fake.py create mode 100644 fastapi_startkit/src/fastapi_startkit/events/helpers.py create mode 100644 fastapi_startkit/src/fastapi_startkit/events/listener.py create mode 100644 fastapi_startkit/src/fastapi_startkit/events/provider.py create mode 100644 fastapi_startkit/src/fastapi_startkit/facades/Event.py create mode 100644 fastapi_startkit/src/fastapi_startkit/facades/Event.pyi create mode 100644 fastapi_startkit/tests/events/__init__.py create mode 100644 fastapi_startkit/tests/events/test_dispatcher.py create mode 100644 fastapi_startkit/tests/events/test_fake.py create mode 100644 fastapi_startkit/tests/events/test_provider.py diff --git a/fastapi_startkit/src/fastapi_startkit/application.py b/fastapi_startkit/src/fastapi_startkit/application.py index 4cd1d14c..ee80c291 100644 --- a/fastapi_startkit/src/fastapi_startkit/application.py +++ b/fastapi_startkit/src/fastapi_startkit/application.py @@ -8,6 +8,7 @@ from .config import AppConfig from .configuration.providers import ConfigurationProvider from .container import Container +from .events import EventServiceProvider from .environment.environment import Environment if TYPE_CHECKING: @@ -27,6 +28,7 @@ def app() -> "Container": class Application(Container, Generic[TConfig]): DEFAULT_PROVIDERS = [ ConfigurationProvider, + EventServiceProvider, AppProvider, ] diff --git a/fastapi_startkit/src/fastapi_startkit/events/__init__.py b/fastapi_startkit/src/fastapi_startkit/events/__init__.py new file mode 100644 index 00000000..a0855ef5 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/events/__init__.py @@ -0,0 +1,13 @@ +from .dispatcher import Dispatcher +from .fake import EventFake +from .helpers import event +from .listener import Listener +from .provider import EventServiceProvider + +__all__ = [ + "Dispatcher", + "EventFake", + "EventServiceProvider", + "Listener", + "event", +] diff --git a/fastapi_startkit/src/fastapi_startkit/events/dispatcher.py b/fastapi_startkit/src/fastapi_startkit/events/dispatcher.py new file mode 100644 index 00000000..a30851a3 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/events/dispatcher.py @@ -0,0 +1,129 @@ +"""Application event dispatcher. + +An async-aware, Laravel-style event dispatcher. Events are plain classes; +listeners are callables or classes exposing a ``handle`` method. Both sync and +coroutine listeners are supported — coroutine results are awaited transparently. +""" + +import inspect +from typing import TYPE_CHECKING, Any, Callable, cast, overload + +if TYPE_CHECKING: + from ..container import Container + from .fake import EventFake + + +class Dispatcher: + def __init__(self, container: "Container | None" = None): + self._container = container + self._listeners: dict[str, list] = {} + + @overload + def listen(self, events, listener: None = None) -> Callable: ... + + @overload + def listen(self, events, listener: Callable) -> None: ... + + def listen(self, events, listener: Callable | None = None): + """Register a listener for one or more events. + + ``events`` may be an event class, a string name, or a list of either. + Used as a decorator when ``listener`` is omitted. + """ + if listener is None: + + def decorator(func: Callable) -> Callable: + self.listen(events, func) + return func + + return decorator + + for name in self._event_names(events): + self._listeners.setdefault(name, []).append(listener) + return None + + def has_listeners(self, event) -> bool: + return bool(self._listeners.get(self._event_key(event))) + + def forget(self, event) -> None: + """Remove all listeners registered for a given event.""" + self._listeners.pop(self._event_key(event), None) + + def flush(self) -> None: + """Remove every registered listener.""" + self._listeners.clear() + + async def dispatch(self, event, payload=None, halt: bool = False): + """Fire an event and call its listeners in registration order. + + Returns the list of listener responses. When ``halt`` is true, the first + non-``None`` response is returned immediately. A listener returning + ``False`` stops propagation to later listeners. + """ + name, args = self._parse_event_payload(event, payload) + + responses: list[Any] = [] + for listener in self._listeners.get(name, []): + response = await self._call_listener(listener, args) + + if halt and response is not None: + return response + + if response is False: + break + + responses.append(response) + + return None if halt else responses + + async def until(self, event, payload=None): + """Dispatch an event, returning the first non-``None`` listener response.""" + return await self.dispatch(event, payload, halt=True) + + def fake(self, events_to_fake=None) -> "EventFake": + """Swap the container's dispatcher for a recording fake (testing helper).""" + from .fake import EventFake + + fake = EventFake(cast("Dispatcher", self), events_to_fake) + if self._container is not None: + self._container.bind("events", fake) + return fake + + def _event_names(self, events) -> list[str]: + items = events if isinstance(events, (list, tuple)) else [events] + return [self._event_key(event) for event in items] + + def _event_key(self, event) -> str: + if isinstance(event, str): + return event + cls = event if inspect.isclass(event) else type(event) + return f"{cls.__module__}.{cls.__qualname__}" + + def _parse_event_payload(self, event, payload) -> tuple[str, list]: + if isinstance(event, str): + if payload is None: + args: list = [] + elif isinstance(payload, (list, tuple)): + args = list(payload) + else: + args = [payload] + return event, args + + return self._event_key(event), [event] + + async def _call_listener(self, listener: Callable, args: list): + handler = self._resolve_listener(listener) + result = handler(*args) + if inspect.isawaitable(result): + result = await result + return result + + def _resolve_listener(self, listener: Callable) -> Callable: + if inspect.isclass(listener): + return self._make(listener).handle + return listener + + def _make(self, cls): + if self._container is not None: + return self._container.resolve(cls) + return cls() diff --git a/fastapi_startkit/src/fastapi_startkit/events/fake.py b/fastapi_startkit/src/fastapi_startkit/events/fake.py new file mode 100644 index 00000000..a9401fa2 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/events/fake.py @@ -0,0 +1,73 @@ +"""A recording fake dispatcher used by ``Event.fake()`` in tests. + +Faked events are captured instead of dispatched to their listeners; any event +not in the fake list is forwarded to the real dispatcher. The recorded events +can then be asserted against. +""" + +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from .dispatcher import Dispatcher + + +class EventFake: + def __init__(self, dispatcher: "Dispatcher", events_to_fake=None): + self._dispatcher = dispatcher + self._events_to_fake = self._normalize(events_to_fake) + self._dispatched: dict[str, list] = {} + + async def dispatch(self, event, payload=None, halt: bool = False): + name, args = self._dispatcher._parse_event_payload(event, payload) + + if self._should_fake(name): + recorded = args if isinstance(event, str) else event + self._dispatched.setdefault(name, []).append(recorded) + return None if halt else [] + + return await self._dispatcher.dispatch(event, payload, halt) + + async def until(self, event, payload=None): + return await self.dispatch(event, payload, halt=True) + + def listen(self, events, listener: Callable | None = None): + return self._dispatcher.listen(events, listener) + + def has_listeners(self, event) -> bool: + return self._dispatcher.has_listeners(event) + + def dispatched(self, event, callback: Callable | None = None) -> list: + """Return recorded dispatches for an event, optionally filtered.""" + records = self._dispatched.get(self._dispatcher._event_key(event), []) + if callback is None: + return list(records) + return [record for record in records if callback(record)] + + def assert_dispatched(self, event, callback: Callable | None = None) -> list: + records = self.dispatched(event, callback) + assert records, f"The expected event [{self._dispatcher._event_key(event)}] was not dispatched." + return records + + def assert_dispatched_times(self, event, times: int = 1) -> None: + count = len(self.dispatched(event)) + assert count == times, ( + f"The expected event [{self._dispatcher._event_key(event)}] was dispatched {count} " + f"time(s) instead of {times} time(s)." + ) + + def assert_not_dispatched(self, event, callback: Callable | None = None) -> None: + count = len(self.dispatched(event, callback)) + assert count == 0, f"The unexpected event [{self._dispatcher._event_key(event)}] was dispatched." + + def assert_nothing_dispatched(self) -> None: + total = sum(len(records) for records in self._dispatched.values()) + assert total == 0, f"{total} unexpected event(s) were dispatched." + + def _normalize(self, events) -> list[str]: + if events is None: + return [] + items = events if isinstance(events, (list, tuple)) else [events] + return [self._dispatcher._event_key(event) for event in items] + + def _should_fake(self, name: str) -> bool: + return not self._events_to_fake or name in self._events_to_fake diff --git a/fastapi_startkit/src/fastapi_startkit/events/helpers.py b/fastapi_startkit/src/fastapi_startkit/events/helpers.py new file mode 100644 index 00000000..0f9518d6 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/events/helpers.py @@ -0,0 +1,19 @@ +"""Global ``event()`` helper mirroring Laravel's event helper.""" + +from typing import cast + +from .dispatcher import Dispatcher + + +def event(event, payload=None, halt: bool = False): + """Dispatch an event through the container's dispatcher. + + Returns the awaitable produced by ``Dispatcher.dispatch`` — callers should + ``await`` it:: + + await event(OrderShipped(order)) + """ + from ..application import app + + dispatcher = cast(Dispatcher, app().make("events")) + return dispatcher.dispatch(event, payload, halt) diff --git a/fastapi_startkit/src/fastapi_startkit/events/listener.py b/fastapi_startkit/src/fastapi_startkit/events/listener.py new file mode 100644 index 00000000..3ea6793a --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/events/listener.py @@ -0,0 +1,16 @@ +"""Optional base class for class-based listeners. + +Listeners are not required to extend this — any callable, or any class exposing +a ``handle`` method, works. It exists to document the contract and to give +type checkers something to lean on. ``handle`` may be sync or async. +""" + +from abc import ABC, abstractmethod +from typing import Any + + +class Listener(ABC): + @abstractmethod + def handle(self, event: Any): + """Handle the given event.""" + ... diff --git a/fastapi_startkit/src/fastapi_startkit/events/provider.py b/fastapi_startkit/src/fastapi_startkit/events/provider.py new file mode 100644 index 00000000..030922f2 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/events/provider.py @@ -0,0 +1,31 @@ +"""Service provider that registers the event dispatcher. + +Subclass and define ``listen`` to map events to their listeners, mirroring +Laravel's ``EventServiceProvider``:: + + class AppEventServiceProvider(EventServiceProvider): + listen = { + UserRegistered: [SendWelcomeEmail, LogRegistration], + } +""" + +from typing import cast + +from fastapi_startkit.support import Provider + +from .dispatcher import Dispatcher + + +class EventServiceProvider(Provider): + provider_key = "events" + + listen: dict = {} + + def register(self) -> None: + self.app.bind("events", Dispatcher(self.app)) + + def boot(self) -> None: + dispatcher = cast(Dispatcher, self.app.make("events")) + for event, listeners in self.listen.items(): + for listener in listeners: + dispatcher.listen(event, listener) diff --git a/fastapi_startkit/src/fastapi_startkit/facades/Event.py b/fastapi_startkit/src/fastapi_startkit/facades/Event.py new file mode 100644 index 00000000..85fbb860 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/facades/Event.py @@ -0,0 +1,5 @@ +from .Facade import Facade + + +class Event(metaclass=Facade): + key = "events" diff --git a/fastapi_startkit/src/fastapi_startkit/facades/Event.pyi b/fastapi_startkit/src/fastapi_startkit/facades/Event.pyi new file mode 100644 index 00000000..87591bef --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/facades/Event.pyi @@ -0,0 +1,41 @@ +from typing import Any, Callable + +from fastapi_startkit.events.fake import EventFake + +class Event: + """Facade for the event dispatcher registered under the 'events' key.""" + + @staticmethod + def listen(events: Any, listener: Callable | None = None) -> Callable | None: + """Register a listener for one or more events (or use as a decorator).""" + ... + + @staticmethod + async def dispatch(event: Any, payload: Any = None, halt: bool = False) -> Any: + """Fire an event and invoke its listeners; returns their responses.""" + ... + + @staticmethod + async def until(event: Any, payload: Any = None) -> Any: + """Dispatch an event, returning the first non-None listener response.""" + ... + + @staticmethod + def has_listeners(event: Any) -> bool: + """Return whether any listener is registered for the event.""" + ... + + @staticmethod + def forget(event: Any) -> None: + """Remove all listeners registered for the event.""" + ... + + @staticmethod + def flush() -> None: + """Remove every registered listener.""" + ... + + @staticmethod + def fake(events_to_fake: Any = None) -> EventFake: + """Swap the dispatcher for a recording fake and return it.""" + ... diff --git a/fastapi_startkit/src/fastapi_startkit/facades/__init__.py b/fastapi_startkit/src/fastapi_startkit/facades/__init__.py index 6376090a..32d40ca0 100644 --- a/fastapi_startkit/src/fastapi_startkit/facades/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/facades/__init__.py @@ -8,6 +8,7 @@ from .View import View from .Gate import Gate from .Config import Config +from .Event import Event from .Loader import Loader from .Notification import Notification from .Dump import Dump diff --git a/fastapi_startkit/tests/events/__init__.py b/fastapi_startkit/tests/events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/events/test_dispatcher.py b/fastapi_startkit/tests/events/test_dispatcher.py new file mode 100644 index 00000000..3fea3832 --- /dev/null +++ b/fastapi_startkit/tests/events/test_dispatcher.py @@ -0,0 +1,228 @@ +import pytest + +from fastapi_startkit.events import Dispatcher, Listener + + +class UserRegistered: + def __init__(self, name: str): + self.name = name + + +class OrderShipped: + def __init__(self, order_id: int): + self.order_id = order_id + + +async def test_sync_listener_receives_event(): + dispatcher = Dispatcher() + seen = [] + dispatcher.listen(UserRegistered, lambda event: seen.append(event.name)) + + await dispatcher.dispatch(UserRegistered("ada")) + + assert seen == ["ada"] + + +async def test_async_listener_is_awaited(): + dispatcher = Dispatcher() + seen = [] + + async def listener(event): + seen.append(event.name) + + dispatcher.listen(UserRegistered, listener) + await dispatcher.dispatch(UserRegistered("lin")) + + assert seen == ["lin"] + + +async def test_class_listener_handle_is_called(): + dispatcher = Dispatcher() + calls = [] + + class SendWelcomeEmail(Listener): + def handle(self, event): + calls.append(event.name) + + dispatcher.listen(UserRegistered, SendWelcomeEmail) + await dispatcher.dispatch(UserRegistered("grace")) + + assert calls == ["grace"] + + +async def test_class_listener_with_async_handle(): + dispatcher = Dispatcher() + calls = [] + + class SendWelcomeEmail: + async def handle(self, event): + calls.append(event.name) + + dispatcher.listen(UserRegistered, SendWelcomeEmail) + await dispatcher.dispatch(UserRegistered("hopper")) + + assert calls == ["hopper"] + + +async def test_multiple_listeners_fire_in_registration_order(): + dispatcher = Dispatcher() + order = [] + dispatcher.listen(UserRegistered, lambda e: order.append("first")) + dispatcher.listen(UserRegistered, lambda e: order.append("second")) + + await dispatcher.dispatch(UserRegistered("x")) + + assert order == ["first", "second"] + + +async def test_dispatch_returns_listener_responses(): + dispatcher = Dispatcher() + dispatcher.listen(UserRegistered, lambda e: "a") + dispatcher.listen(UserRegistered, lambda e: "b") + + responses = await dispatcher.dispatch(UserRegistered("x")) + + assert responses == ["a", "b"] + + +async def test_returning_false_stops_propagation(): + dispatcher = Dispatcher() + order = [] + dispatcher.listen(UserRegistered, lambda e: order.append("first") or False) + dispatcher.listen(UserRegistered, lambda e: order.append("second")) + + await dispatcher.dispatch(UserRegistered("x")) + + assert order == ["first"] + + +async def test_until_returns_first_non_none_response(): + dispatcher = Dispatcher() + dispatcher.listen(UserRegistered, lambda e: None) + dispatcher.listen(UserRegistered, lambda e: "handled") + dispatcher.listen(UserRegistered, lambda e: "ignored") + + result = await dispatcher.until(UserRegistered("x")) + + assert result == "handled" + + +async def test_string_event_with_payload_list_is_spread(): + dispatcher = Dispatcher() + captured = [] + dispatcher.listen("user.registered", lambda name, plan: captured.append((name, plan))) + + await dispatcher.dispatch("user.registered", ["ada", "pro"]) + + assert captured == [("ada", "pro")] + + +async def test_string_event_with_no_payload(): + dispatcher = Dispatcher() + calls = [] + dispatcher.listen("cache.flushed", lambda: calls.append(True)) + + await dispatcher.dispatch("cache.flushed") + + assert calls == [True] + + +async def test_string_event_with_scalar_payload(): + dispatcher = Dispatcher() + captured = [] + dispatcher.listen("cache.cleared", lambda key: captured.append(key)) + + await dispatcher.dispatch("cache.cleared", "users") + + assert captured == ["users"] + + +async def test_listen_accepts_a_list_of_events(): + dispatcher = Dispatcher() + seen = [] + dispatcher.listen([UserRegistered, OrderShipped], lambda e: seen.append(type(e).__name__)) + + await dispatcher.dispatch(UserRegistered("x")) + await dispatcher.dispatch(OrderShipped(1)) + + assert seen == ["UserRegistered", "OrderShipped"] + + +async def test_listen_as_decorator(): + dispatcher = Dispatcher() + seen = [] + + @dispatcher.listen(UserRegistered) + def handler(event): + seen.append(event.name) + + await dispatcher.dispatch(UserRegistered("curie")) + + assert seen == ["curie"] + assert callable(handler) + + +def test_has_listeners(): + dispatcher = Dispatcher() + assert dispatcher.has_listeners(UserRegistered) is False + dispatcher.listen(UserRegistered, lambda e: None) + assert dispatcher.has_listeners(UserRegistered) is True + + +async def test_forget_removes_listeners(): + dispatcher = Dispatcher() + seen = [] + dispatcher.listen(UserRegistered, lambda e: seen.append(1)) + dispatcher.forget(UserRegistered) + + await dispatcher.dispatch(UserRegistered("x")) + + assert seen == [] + assert dispatcher.has_listeners(UserRegistered) is False + + +async def test_flush_removes_all_listeners(): + dispatcher = Dispatcher() + dispatcher.listen(UserRegistered, lambda e: None) + dispatcher.listen(OrderShipped, lambda e: None) + + dispatcher.flush() + + assert dispatcher.has_listeners(UserRegistered) is False + assert dispatcher.has_listeners(OrderShipped) is False + + +async def test_dispatch_with_no_listeners_returns_empty_list(): + dispatcher = Dispatcher() + assert await dispatcher.dispatch(UserRegistered("x")) == [] + + +async def test_listener_class_dependencies_resolved_by_container(): + from fastapi_startkit.container import Container + + class Mailer: + def __init__(self): + self.sent = [] + + container = Container() + mailer = Mailer() + container.bind("mailer", mailer) + + class SendWelcomeEmail: + def __init__(self, mailer: Mailer): + self.mailer = mailer + + def handle(self, event): + self.mailer.sent.append(event.name) + + dispatcher = Dispatcher(container) + dispatcher.listen(UserRegistered, SendWelcomeEmail) + + await dispatcher.dispatch(UserRegistered("noether")) + + assert mailer.sent == ["noether"] + + +def test_listener_abc_requires_handle(): + with pytest.raises(TypeError): + Listener() # type: ignore[abstract] diff --git a/fastapi_startkit/tests/events/test_fake.py b/fastapi_startkit/tests/events/test_fake.py new file mode 100644 index 00000000..fcbfd41f --- /dev/null +++ b/fastapi_startkit/tests/events/test_fake.py @@ -0,0 +1,127 @@ +import pytest + +from fastapi_startkit.events import Dispatcher, EventFake + + +class UserRegistered: + def __init__(self, name: str): + self.name = name + + +class OrderShipped: + def __init__(self, order_id: int): + self.order_id = order_id + + +def make_fake(events_to_fake=None): + return EventFake(Dispatcher(), events_to_fake) + + +async def test_faked_event_is_not_dispatched_to_listeners(): + dispatcher = Dispatcher() + called = [] + dispatcher.listen(UserRegistered, lambda e: called.append(e.name)) + fake = dispatcher.fake() + + await fake.dispatch(UserRegistered("ada")) + + assert called == [] + + +async def test_assert_dispatched_passes_when_dispatched(): + fake = make_fake() + await fake.dispatch(UserRegistered("ada")) + + fake.assert_dispatched(UserRegistered) + + +async def test_assert_dispatched_fails_when_not_dispatched(): + fake = make_fake() + with pytest.raises(AssertionError): + fake.assert_dispatched(UserRegistered) + + +async def test_assert_dispatched_with_callback(): + fake = make_fake() + await fake.dispatch(UserRegistered("ada")) + + fake.assert_dispatched(UserRegistered, lambda e: e.name == "ada") + with pytest.raises(AssertionError): + fake.assert_dispatched(UserRegistered, lambda e: e.name == "other") + + +async def test_assert_dispatched_times(): + fake = make_fake() + await fake.dispatch(UserRegistered("a")) + await fake.dispatch(UserRegistered("b")) + + fake.assert_dispatched_times(UserRegistered, 2) + with pytest.raises(AssertionError): + fake.assert_dispatched_times(UserRegistered, 1) + + +async def test_assert_not_dispatched(): + fake = make_fake() + fake.assert_not_dispatched(UserRegistered) + + await fake.dispatch(UserRegistered("a")) + with pytest.raises(AssertionError): + fake.assert_not_dispatched(UserRegistered) + + +async def test_assert_nothing_dispatched(): + fake = make_fake() + fake.assert_nothing_dispatched() + + await fake.dispatch(UserRegistered("a")) + with pytest.raises(AssertionError): + fake.assert_nothing_dispatched() + + +async def test_dispatched_returns_recorded_events(): + fake = make_fake() + await fake.dispatch(UserRegistered("a")) + await fake.dispatch(UserRegistered("b")) + + records = fake.dispatched(UserRegistered) + assert [e.name for e in records] == ["a", "b"] + + +async def test_partial_fake_forwards_unfaked_events(): + dispatcher = Dispatcher() + shipped = [] + dispatcher.listen(OrderShipped, lambda e: shipped.append(e.order_id)) + fake = EventFake(dispatcher, [UserRegistered]) + + await fake.dispatch(UserRegistered("ada")) + await fake.dispatch(OrderShipped(7)) + + fake.assert_dispatched(UserRegistered) + fake.assert_not_dispatched(OrderShipped) + assert shipped == [7] + + +async def test_string_event_records_payload(): + fake = make_fake() + await fake.dispatch("user.registered", ["ada", "pro"]) + + fake.assert_dispatched("user.registered") + assert fake.dispatched("user.registered") == [["ada", "pro"]] + + +async def test_until_records_faked_event(): + fake = make_fake() + result = await fake.until(UserRegistered("ada")) + + assert result is None + fake.assert_dispatched(UserRegistered) + + +def test_listen_and_has_listeners_pass_through_to_real_dispatcher(): + dispatcher = Dispatcher() + fake = EventFake(dispatcher) + + fake.listen(UserRegistered, lambda e: None) + + assert fake.has_listeners(UserRegistered) is True + assert dispatcher.has_listeners(UserRegistered) is True diff --git a/fastapi_startkit/tests/events/test_provider.py b/fastapi_startkit/tests/events/test_provider.py new file mode 100644 index 00000000..dccccdba --- /dev/null +++ b/fastapi_startkit/tests/events/test_provider.py @@ -0,0 +1,81 @@ +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.events import Dispatcher, EventServiceProvider + + +class UserRegistered: + def __init__(self, name: str): + self.name = name + + +received = [] + + +class RecordRegistration: + def handle(self, event): + received.append(event.name) + + +class AppEventServiceProvider(EventServiceProvider): + listen = { + UserRegistered: [RecordRegistration], + } + + +@pytest.fixture(autouse=True) +def restore_container_instance(): + original = Container._instance + received.clear() + yield + Container._instance = original + + +@pytest.fixture +def app(tmp_path): + return Application(base_path=tmp_path, env="testing", providers=[AppEventServiceProvider]) + + +def test_dispatcher_is_bound_by_default(tmp_path): + app = Application(base_path=tmp_path, env="testing") + assert app.has("events") + assert isinstance(app.make("events"), Dispatcher) + + +async def test_provider_wires_listen_map(app): + await app.make("events").dispatch(UserRegistered("ada")) + assert received == ["ada"] + + +async def test_event_facade_dispatches(app): + from fastapi_startkit.facades import Event + + seen = [] + Event.listen(UserRegistered, lambda e: seen.append(e.name)) + await Event.dispatch(UserRegistered("lin")) + + assert seen == ["lin"] + + +async def test_event_helper_dispatches(app): + from fastapi_startkit.events import event + + seen = [] + app.make("events").listen(UserRegistered, lambda e: seen.append(e.name)) + await event(UserRegistered("grace")) + + assert seen == ["grace"] + + +async def test_event_facade_fake_records_without_calling_listeners(app): + from fastapi_startkit.facades import Event + + called = [] + Event.listen(UserRegistered, lambda e: called.append(e.name)) + + fake = Event.fake() + await Event.dispatch(UserRegistered("noether")) + + assert called == [] + fake.assert_dispatched(UserRegistered) From 88537bf8dc74e5bc55f38c5de22ce51f8d05b69d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 13 Jul 2026 01:26:35 -0700 Subject: [PATCH 2/3] feat(events): support fnmatch-style wildcard listeners Event.listen('user.*') now fires for any matching event name. dispatch() (and until/halt and has_listeners) collect exact-key listeners first, then any wildcard-pattern listeners whose pattern matches the dispatched name via fnmatchcase. Non-matching events do not fire. Adds tests for match, non-match, multi-pattern, wildcard+exact ordering, multi-segment match, until/halt, and has_listeners. --- .../src/fastapi_startkit/events/dispatcher.py | 18 ++++- .../tests/events/test_dispatcher.py | 81 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/events/dispatcher.py b/fastapi_startkit/src/fastapi_startkit/events/dispatcher.py index a30851a3..19bf9c2d 100644 --- a/fastapi_startkit/src/fastapi_startkit/events/dispatcher.py +++ b/fastapi_startkit/src/fastapi_startkit/events/dispatcher.py @@ -6,6 +6,7 @@ """ import inspect +from fnmatch import fnmatchcase from typing import TYPE_CHECKING, Any, Callable, cast, overload if TYPE_CHECKING: @@ -28,7 +29,9 @@ def listen(self, events, listener: Callable | None = None): """Register a listener for one or more events. ``events`` may be an event class, a string name, or a list of either. - Used as a decorator when ``listener`` is omitted. + String names may contain ``*`` wildcards (e.g. ``"user.*"``) — the + listener then fires for any matching event name. Used as a decorator + when ``listener`` is omitted. """ if listener is None: @@ -43,7 +46,7 @@ def decorator(func: Callable) -> Callable: return None def has_listeners(self, event) -> bool: - return bool(self._listeners.get(self._event_key(event))) + return bool(self._get_listeners(self._event_key(event))) def forget(self, event) -> None: """Remove all listeners registered for a given event.""" @@ -63,7 +66,7 @@ async def dispatch(self, event, payload=None, halt: bool = False): name, args = self._parse_event_payload(event, payload) responses: list[Any] = [] - for listener in self._listeners.get(name, []): + for listener in self._get_listeners(name): response = await self._call_listener(listener, args) if halt and response is not None: @@ -93,6 +96,15 @@ def _event_names(self, events) -> list[str]: items = events if isinstance(events, (list, tuple)) else [events] return [self._event_key(event) for event in items] + def _get_listeners(self, name: str) -> list: + """All listeners for an event name: exact matches first, then any + wildcard-pattern listeners whose pattern matches the name.""" + listeners = list(self._listeners.get(name, [])) + for pattern, pattern_listeners in self._listeners.items(): + if pattern != name and "*" in pattern and fnmatchcase(name, pattern): + listeners.extend(pattern_listeners) + return listeners + def _event_key(self, event) -> str: if isinstance(event, str): return event diff --git a/fastapi_startkit/tests/events/test_dispatcher.py b/fastapi_startkit/tests/events/test_dispatcher.py index 3fea3832..77a3d612 100644 --- a/fastapi_startkit/tests/events/test_dispatcher.py +++ b/fastapi_startkit/tests/events/test_dispatcher.py @@ -162,6 +162,87 @@ def handler(event): assert callable(handler) +async def test_wildcard_listener_fires_on_matching_event(): + dispatcher = Dispatcher() + seen = [] + dispatcher.listen("user.*", lambda: seen.append(True)) + + await dispatcher.dispatch("user.created") + + assert seen == [True] + + +async def test_wildcard_listener_does_not_fire_on_non_matching_event(): + dispatcher = Dispatcher() + seen = [] + dispatcher.listen("user.*", lambda: seen.append(True)) + + await dispatcher.dispatch("order.created") + + assert seen == [] + + +async def test_wildcard_receives_event_payload(): + dispatcher = Dispatcher() + captured = [] + dispatcher.listen("user.*", lambda name: captured.append(name)) + + await dispatcher.dispatch("user.created", "ada") + + assert captured == ["ada"] + + +async def test_multiple_wildcard_patterns_each_match_independently(): + dispatcher = Dispatcher() + seen = [] + dispatcher.listen("user.*", lambda: seen.append("user")) + dispatcher.listen("*.created", lambda: seen.append("created")) + dispatcher.listen("order.*", lambda: seen.append("order")) + + await dispatcher.dispatch("user.created") + + assert sorted(seen) == ["created", "user"] + + +async def test_wildcard_and_exact_listeners_both_fire_exact_first(): + dispatcher = Dispatcher() + order = [] + dispatcher.listen("user.*", lambda: order.append("wildcard")) + dispatcher.listen("user.created", lambda: order.append("exact")) + + await dispatcher.dispatch("user.created") + + assert order == ["exact", "wildcard"] + + +async def test_wildcard_matches_across_multiple_segments(): + dispatcher = Dispatcher() + seen = [] + dispatcher.listen("user.*", lambda: seen.append(True)) + + await dispatcher.dispatch("user.profile.updated") + + assert seen == [True] + + +async def test_until_halts_across_exact_and_wildcard(): + dispatcher = Dispatcher() + dispatcher.listen("user.created", lambda: None) + dispatcher.listen("user.*", lambda: "handled") + + result = await dispatcher.until("user.created") + + assert result == "handled" + + +def test_has_listeners_true_for_wildcard_match(): + dispatcher = Dispatcher() + dispatcher.listen("user.*", lambda: None) + + assert dispatcher.has_listeners("user.created") is True + assert dispatcher.has_listeners("order.created") is False + + def test_has_listeners(): dispatcher = Dispatcher() assert dispatcher.has_listeners(UserRegistered) is False From c58b9a160fd7878dc555586efc4c59d1859ec0a7 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 13 Jul 2026 01:28:20 -0700 Subject: [PATCH 3/3] fix(events): add forget()/flush() passthrough to EventFake The Event facade stub exposes forget() and flush(), but EventFake did not implement them, so calling Event.forget()/Event.flush() while faked raised AttributeError. Forward both to the real dispatcher to match its surface. --- .../src/fastapi_startkit/events/fake.py | 6 ++++++ fastapi_startkit/tests/events/test_fake.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/fastapi_startkit/src/fastapi_startkit/events/fake.py b/fastapi_startkit/src/fastapi_startkit/events/fake.py index a9401fa2..086e20d0 100644 --- a/fastapi_startkit/src/fastapi_startkit/events/fake.py +++ b/fastapi_startkit/src/fastapi_startkit/events/fake.py @@ -36,6 +36,12 @@ def listen(self, events, listener: Callable | None = None): def has_listeners(self, event) -> bool: return self._dispatcher.has_listeners(event) + def forget(self, event) -> None: + self._dispatcher.forget(event) + + def flush(self) -> None: + self._dispatcher.flush() + def dispatched(self, event, callback: Callable | None = None) -> list: """Return recorded dispatches for an event, optionally filtered.""" records = self._dispatched.get(self._dispatcher._event_key(event), []) diff --git a/fastapi_startkit/tests/events/test_fake.py b/fastapi_startkit/tests/events/test_fake.py index fcbfd41f..b1baf795 100644 --- a/fastapi_startkit/tests/events/test_fake.py +++ b/fastapi_startkit/tests/events/test_fake.py @@ -125,3 +125,17 @@ def test_listen_and_has_listeners_pass_through_to_real_dispatcher(): assert fake.has_listeners(UserRegistered) is True assert dispatcher.has_listeners(UserRegistered) is True + + +def test_forget_and_flush_pass_through_to_real_dispatcher(): + dispatcher = Dispatcher() + fake = EventFake(dispatcher) + fake.listen(UserRegistered, lambda e: None) + fake.listen(OrderShipped, lambda e: None) + + fake.forget(UserRegistered) + assert fake.has_listeners(UserRegistered) is False + assert fake.has_listeners(OrderShipped) is True + + fake.flush() + assert fake.has_listeners(OrderShipped) is False