Skip to main content

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.

python 3.11+ asyncio core dependencies: 3 typed license MIT

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_listener on a class, a method or a function, and the container wires it, ordered by priority and before/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 base Event does 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, else on_<event> (on_order_placed for OrderPlaced or "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 a priority keeps it and is only reordered among its equals; one without takes the priority its place needs. A target naming something not installed is ignored; a Class.method whose class listens through another method is an error. An inherited method is also known by the class defining it.
  • dispatcher — a named dispatcher from EventDispatcherConfig.dispatchers, injected with Annotated[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)

Source distribution for xtr-event-dispatcher 1.3.0
File Size Uploaded
xtr_event_dispatcher-1.3.0.tar.gz 36.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xtr-event-dispatcher 1.3.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

1.4.0

2 release files

This release

1.3.0 This release

2 release files

1.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page