Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -27,6 +28,7 @@ def app() -> "Container":
class Application(Container, Generic[TConfig]):
DEFAULT_PROVIDERS = [
ConfigurationProvider,
EventServiceProvider,
AppProvider,
]

Expand Down
13 changes: 13 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/events/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
141 changes: 141 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/events/dispatcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""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 fnmatch import fnmatchcase
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.
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:

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._get_listeners(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._get_listeners(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 _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
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()
79 changes: 79 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/events/fake.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""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 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), [])
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
19 changes: 19 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/events/helpers.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/events/listener.py
Original file line number Diff line number Diff line change
@@ -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."""
...
31 changes: 31 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/events/provider.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/facades/Event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .Facade import Facade


class Event(metaclass=Facade):
key = "events"
41 changes: 41 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/facades/Event.pyi
Original file line number Diff line number Diff line change
@@ -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."""
...
1 change: 1 addition & 0 deletions fastapi_startkit/src/fastapi_startkit/facades/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file.
Loading
Loading