xtr-event-dispatcher
Let the parts of an application talk through events: listeners and subscribers run by priority, any of them able to stop the rest.
Why?
When placing an order has to send a receipt, update stock and write an audit line, the code that
places it should not call all three. It dispatches an OrderPlaced event; each concern listens
for it on its own, in the order their priorities give, and any listener can stop the ones after
it. Adding a fourth concern is adding a listener, not editing the order code.
- 📣 Async dispatch — listeners are plain functions or
async def, awaited one after another. - 🔢 Priorities and stopping — higher runs first; an event a listener stops goes no further.
- 🧾 Subscribers — a class declaring every event it listens to, in one place.
- 💤 Lazy listeners — the object a listener belongs to is built when an event first reaches it.
- 🔒 Immutable, scoped, compiled — hand a dispatcher out without letting anyone change it, and add listeners for one scope without touching a shared one.
- 🧩 A bundle —
@as_event_listeneron a class, a method or a function, and the container wires it, ordered by priority andbefore/after. - 🔍 Tracing — which listeners ran, which did not, and which events nobody heard.
Install
uv add xtr-event-dispatcher
With a container:
uv add "xtr-event-dispatcher[di]"
A library that only dispatches events depends on xtr-event-dispatcher-contracts instead, and leaves the dispatcher to the application.
Quick start
from dataclasses import dataclass
from xtr_event_dispatcher import Event, EventDispatcher
@dataclass(frozen=True)
class OrderPlaced(Event):
order_id: int
async def send_receipt(event: OrderPlaced) -> None: ...
def reject_fraud(event: OrderPlaced) -> None:
if is_fraudulent(event.order_id):
event.stop_propagation() # send_receipt will not run
dispatcher = EventDispatcher()
dispatcher.add_listener(OrderPlaced, reject_fraud, priority=100)
dispatcher.add_listener(OrderPlaced, send_receipt)
await dispatcher.dispatch(OrderPlaced(42))
Events and names
An event is any object; derive from Event to let listeners stop it. Events are keyed by
name: a string, or a class standing for "<module>.<qualname>". dispatch(event) without a
name uses the event's class, so add_listener(OrderPlaced, ...) and
dispatch(OrderPlaced(42)) meet. A listener of a base class does not hear its subclasses: a
package wanting a broad audience dispatches one event class per situation.
GenericEvent is an event without a class of its own: a subject, and arguments read like a
mapping.
event = await dispatcher.dispatch(GenericEvent(order, {"notify": True}), "order.saved")
if event["notify"]:
...
Listeners
A listener is called with up to three arguments — the event, the name it was dispatched under,
and the dispatcher — as many as it takes, by position. What it returns is ignored, except
that an awaitable is awaited before the next listener runs. Adding one that requires more
raises ListenerSignatureError there and then.
def log(event: OrderPlaced) -> None: ...
def route(event: Event, name: str) -> None: ...
async def chain(event: Event, name: str, dispatcher: EventDispatcherInterface) -> None: ...
Listeners run by priority, highest first; sharing a priority, in the order added. Bound methods
of one object compare equal, so remove_listener(event, obj.method) removes what
add_listener(event, obj.method) added.
Subscribers
class OrderMailer(EventSubscriberInterface):
@classmethod
@override
def get_subscribed_events(cls) -> Mapping[str | type, SubscribedEvents]:
return {
OrderPlaced: "on_placed", # priority 0
OrderShipped: ("on_shipped", 10), # (method, priority)
OrderCancelled: [ # several listeners
"notify_customer",
{"method": "notify_warehouse", "priority": -5},
],
}
dispatcher.add_subscriber(OrderMailer())
A mapping may also carry before/after (see below); a dispatcher on its own cannot honour
them, so it requires a priority beside them and ignores them — a container orders by them.
Lazy listeners
dispatcher.add_listener(OrderPlaced, LazyListener(build_mailer, "on_placed"))
build_mailer is awaited just before the listener first runs — never, when an earlier listener
stops the event — and what it built is kept. Until then get_listeners returns the
LazyListener; afterwards, the bound method.
Dispatchers
| Class | What it is for |
|---|---|
EventDispatcher |
The one listeners are registered on. |
ImmutableEventDispatcher(dispatcher) |
Hand a dispatcher out for dispatching only: every change raises BadMethodCallError. |
ScopedEventDispatcher(dispatcher) |
Listeners for one scope — a request, a command, a test — next to those of a shared dispatcher, which is left as it is. They interleave by priority. |
CompiledEventDispatcher(listeners) |
Listeners fixed when it is built, then refusing every change. What the container builds. |
debug.TraceableEventDispatcher(dispatcher, logger=None) |
Records which listeners ran and how often, which did not, and which events nobody heard; reset() between units of work. |
A listener receives the dispatcher that ran it: the scoped, compiled or traceable one itself, but
the wrapped one through an ImmutableEventDispatcher, which dispatches by delegating.
Kernel / bundle
# app/bundles.py
from xtr_event_dispatcher.bundle import EventDispatcherBundle
BUNDLES = {EventDispatcherBundle: {"all": True}}
The application gets a dispatcher under EventDispatcherInterface — the contract's and this
package's — and ListenerIntrospectionInterface, holding every listener its scan finds:
from xtr_dependency_injection import Injected
from xtr_event_dispatcher import as_event_listener
@as_event_listener() # the event is the first parameter's type
async def send_receipt(event: OrderPlaced, mailer: Injected[Mailer]) -> None: ...
class Stock: # built when an event first reaches it
def __init__(self, repository: StockRepository) -> None: ...
@as_event_listener(priority=10)
def reserve(self, event: OrderPlaced | OrderEdited) -> None: ...
@as_event_listener(OrderShipped) # calls on_order_shipped, else __call__
class Notifier:
def on_order_shipped(self, event: OrderShipped) -> None: ...
class Audit(EventSubscriberInterface): ... # every subscriber is registered
@as_event_listener(event=None, *, method=None, priority=None, dispatcher=None, before=None, after=None), repeatable, on a class, a method — static and class methods included, above or
below their decorator — or a function:
event— a name or a class. Without it, the first parameter's annotation — each member of a union — is the event; the baseEventdoes not count. Annotations must be importable at runtime.method— on a class only. Without it, the class is called through__call__when the event is read from its signature, elseon_<event>(on_order_placedforOrderPlacedor"order.placed"), then__call__. On a method it is an error: the method is the one called.before/after— classes, functions or methods, or"module:Qualified.name"strings; a callable object is refused. A listener with aprioritykeeps it and is only reordered among its equals; one without takes the priority its place needs. A target naming something not installed is ignored; aClass.methodwhose class listens through another method is an error. An inherited method is also known by the class defining it.dispatcher— a named dispatcher fromEventDispatcherConfig.dispatchers, injected withAnnotated[EventDispatcherInterface, Target("audit")].
A function's own parameters come first; the ones the container fills — Injected[...],
Autowire(...), Target(...) — follow. A listener class is a singleton: the dispatcher keeps
it once built.
Listener methods are inherited: every scanned class carrying one listens, its subclasses
included. To keep a base class from listening itself, make it abstract (an ABC with an
abstract method) or mark it @exclude. A subscriber base that leaves get_subscribed_events
to its subclasses is not registered either.
The container's dispatcher cannot be changed. It is a CompiledEventDispatcher shared for
the process's lifetime, so a listener added at runtime would outlive the request, message or
task that added it; a listener receiving the dispatcher receives that one too. For listeners of
one scope, wrap it: ScopedEventDispatcher(dispatcher).
A bundle tags a service by hand the way the decorator does:
services.set(Stock).add_tag("event_dispatcher.listener", event=OrderPlaced, method="reserve")
services.set(Audit).add_tag("event_dispatcher.subscriber", dispatcher="audit")
Configuration
@configure
def events() -> EventDispatcherConfig:
return EventDispatcherConfig(
# a listener of "order.placed" hears OrderPlaced
event_aliases={"order.placed": OrderPlaced},
dispatchers=("audit",), # besides the default one
trace=None, # None: trace in debug mode
)
Another bundle adds aliases with builder.prepend_extension_config("event_dispatcher", ...).
In debug mode every dispatcher is wrapped in a TraceableEventDispatcher, reset between units of
work (kernel.reset); with the logging bundle active it writes to the "event" channel.
The bundle's zero-config path builds an empty dispatcher and does no I/O.
Errors
Every error derives from EventDispatcherError. An exception raised by a listener is not
wrapped: it reaches the code that dispatched the event.
| Error | Raised when |
|---|---|
ListenerSignatureError (TypeError) |
A listener requires more than the event, its name and the dispatcher |
InvalidSubscriberError (ValueError) |
A subscriber's declaration has no known shape, names a method it lacks, or orders without a priority on a plain dispatcher |
InvalidListenerError (ValueError) |
While the container is built: no event to read, a missing method, an unknown dispatcher, ordering constraints that cannot be met |
BadMethodCallError (RuntimeError) |
A change to an immutable or compiled dispatcher |
ArgumentNotFoundError (KeyError) |
A GenericEvent has no such argument |
InvalidArgumentError (ValueError) |
An EventDispatcherConfig cannot be read |
Development
Developed in the python-xtr monorepo, under
packages/xtr-event-dispatcher; run the commands below from there. The
python-xtr-event-dispatcher repository is a read-only copy, so send issues and pull requests to
the monorepo.
uv sync
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
License
MIT — see LICENSE.
Release files for xtr-event-dispatcher 1.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xtr_event_dispatcher-1.3.0.tar.gz | 36.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xtr_event_dispatcher-1.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 92.0 kB
Release files / xtr_event_dispatcher-1.3.0.tar.gz
| Download URL | xtr_event_dispatcher-1.3.0.tar.gz |
|---|---|
| Size | 36.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c9f290c0b1f383bc6e9fcd33106c5ea3e040ddba97ad4ff83be37079e5413a82
|
|
BLAKE2b-256 checksum How to use checksums |
f29811417035cd967d7e631607c6da97561445ec56d57840b22c9604d015afd8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency logRelease files / xtr_event_dispatcher-1.3.0-py3-none-any.whl
| Download URL | xtr_event_dispatcher-1.3.0-py3-none-any.whl |
|---|---|
| Size | 55.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
842ef2394d229ee4fb24a9468c9e31fadb442169c7f72893ecc5bfb22891750d
|
|
BLAKE2b-256 checksum How to use checksums |
5a9fa87727e4d9e14532f4dcfc78dc95c1a041b214934b2011d156cada20820d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency log