Skip to main content

RelayKit Tools

A tiny, dependency-free event bus for Python.

PyPI Python versions License: MIT

RelayKit lets the parts of an application talk to each other through events instead of direct calls. A module publishes what happened; anyone who cares subscribes. Neither side needs to import the other, so features can be added, removed or tested in isolation.

from relaykit_tools import EventBus

bus = EventBus()

@bus.on("user.created")
def send_welcome_email(user_id):
    print(f"Welcome, user {user_id}!")

bus.emit("user.created", 42)   # Welcome, user 42!
  • No dependencies, no framework, no configuration.
  • Sync and async handlers, ordered by priority.
  • Wildcard patterns ("*", "user.*") and namespaced scopes.
  • Built-in recorder for asserting on events in tests.
  • Under 1,000 lines of type-annotated Python, Python 3.10+.

Installation

pip install relaykit-tools

Contents


Why an event bus?

Without a bus, the code that knows what happened has to know who cares:

def register_user(email):
    user_id = save_user(email)      # core
    send_welcome_email(user_id)     # marketing
    create_default_settings(user_id)  # settings
    index_for_search(user_id)       # search

Every new reaction edits register_user, imports another module, and grows the test setup. With a bus, the core publishes one fact and stays closed for modification:

def register_user(bus, email):
    user_id = save_user(email)
    bus.emit("user.created", user_id)
    return user_id

Subscribers live wherever they belong:

@bus.on("user.created")
def send_welcome_email(user_id): ...

@bus.on("user.created")
def create_default_settings(user_id): ...

@bus.on("user.created")
def index_for_search(user_id): ...

The trade-off is intentional: an event bus decouples producers from consumers, which makes flows harder to follow in exchange for making them easy to extend. It shines in apps with cross-cutting reactions, plugin-style extension points, and tests that need to observe behaviour without mocks.

Quick start

from relaykit_tools import EventBus

bus = EventBus()

# A handler can be any callable.
def greet(name):
    print(f"Hello, {name}!")

bus.on("user.joined", greet)
bus.emit("user.joined", "Alex")
# Hello, Alex!

emit returns how many handlers ran, which makes "did anything listen?" easy to check:

print(bus.emit("user.joined", "Sam"))  # 1
print(bus.emit("user.left", "Sam"))    # 0

Subscribe with a decorator

@bus.on("order.created")
def process_order(order_id):
    print(f"Processing order {order_id}")

bus.emit("order.created", 123)

Run a handler once

@bus.once("app.started")
def warm_caches():
    print("Caches warmed")

bus.emit("app.started")   # Caches warmed
bus.emit("app.started")   # nothing happens

Watch everything

A wildcard handler receives the event name as its first argument:

@bus.on("*")
def log_everything(event_name, *args, **kwargs):
    print(f"{event_name} {args} {kwargs}")

bus.emit("user.created", 42)
# user.created (42,)

Unsubscribe

bus.off("user.joined", greet)   # one handler
bus.off("user.joined")          # every handler for that event
bus.clear()                     # everything on the bus

Core concepts

Concept What it is
Event A string name plus the arguments it was published with, e.g. "order.paid". Names are case-sensitive and dotted names are the convention.
Handler Any callable (def, lambda, method, callable object). Async functions are supported.
Bus EventBus — the registry of handlers and the dispatcher. Most apps create one and share it.
Pattern A subscription that matches several event names: "*" or "user.*".
Scope bus.scope("user") — a view that prefixes every event with a namespace and can remove everything it registered in one call.
Recorder EventRecorder(bus) — records events so tests can assert on them.

Use cases

1. Decouple modules inside one app

A checkout service publishes facts; invoicing, email and analytics react without the checkout code importing any of them.

# checkout.py
def checkout(bus, cart, user):
    order_id = create_order(cart, user)
    bus.emit("order.paid", order_id=order_id, total=cart.total, user_id=user.id)
    return order_id

# billing.py
@bus.on("order.paid")
def create_invoice(order_id, total, user_id):
    invoices.create(order_id=order_id, amount=total, customer=user_id)

# analytics.py
@bus.on("order.paid")
def track_purchase(order_id, total, user_id):
    analytics.track("purchase", {"order": order_id, "value": total})

Wiring happens once, usually where the bus is created:

bus = EventBus()
import billing, analytics   # each module subscribes at import time

Prefer explicit registration in one place (wiring.py) if import side effects make you uneasy — both work.

2. Plugin systems and ordered hooks

Higher-priority handlers run first, which is how you put auditing or validation in front of "normal" work:

@bus.on("*", priority=100)
def audit(event_name, *args, **kwargs):
    audit_log.write(event_name, args, kwargs)

@bus.on("document.save", priority=10)
def validate(document):
    if not document.is_valid():
        raise ValueError("invalid document")

@bus.on("document.save")
def save(document):
    documents.save(document)

document.save now runs audit → validate → save, and the audit log also sees every other event on the bus.

3. Namespaces and request-scoped listeners

bus.scope("user") prefixes registration and emission, so a module can say "created" instead of repeating "user.created":

users = bus.scope("user")

@users.on("created")
def send_welcome(user_id): ...

users.emit("created", 42)     # publishes "user.created"

A scope can remove everything it registered, which is ideal for something with a lifetime — a request, a session, a plugin you can install and uninstall:

def handle_request(bus, request):
    with bus.scope(f"request.{request.id}") as scope:
        scope.on("files.changed", request.refresh)
        bus.emit(f"request.{request.id}.files.changed")
    # listeners are gone here

Subscribing with "*" inside a scope listens to that namespace only:

users.on("*", lambda name, *args: print("user event:", name))
users.emit("deleted", 7)   # user event: user.deleted

Nested scopes compose: bus.scope("order").scope("checkout") is "order.checkout", and closing the parent closes the children too.

4. Async handlers

Register coroutine functions normally; await them with emit_async:

@bus.on("upload.finished")
async def resize(upload_id):
    await images.resize(upload_id)

@bus.on("upload.finished")          # sync handlers can be mixed in
def index(upload_id):
    search.index(upload_id)

await bus.emit_async("upload.finished", 3)

Handlers run in order, and each async handler is awaited before the next one starts. If you call plain emit() on an event with an async handler, RelayKit raises a TypeError that tells you to use emit_async() — it never silently drops a coroutine.

5. Wait for something to happen

wait_for returns the next matching Event, which is convenient for startup handshakes and integration tests:

event = await bus.wait_for("app.ready", timeout=5)
print(event.name, event.kwargs)      # app.ready {'port': 8080}

wait_for also accepts patterns and a predicate:

failure = await bus.wait_for(
    "health.check",
    timeout=30,
    predicate=lambda event: not event.kwargs["ok"],
)

It unsubscribes itself afterwards, including when it times out (asyncio.TimeoutError).

6. Test what a function publishes

The recorder removes the need to mock collaborators that would have been called directly:

from relaykit_tools import EventBus, EventRecorder

def test_register_user_emits_events():
    bus = EventBus()
    recorder = EventRecorder(bus)

    with recorder:
        register_user(bus, "alex@example.com")

    assert recorder.names() == ["user.registered", "email.welcome_requested"]
    event = recorder.assert_emitted("user.registered")
    assert event.kwargs["email"] == "alex@example.com"
    recorder.assert_not_emitted("email.bounced")

Record only one namespace with EventRecorder(bus, "email.*"), cap memory with limit=, or drive it by hand with start() / stop().

7. Observe without touching the code

A single wildcard listener gives you logging, metrics or tracing for free:

@bus.on("*")
def metrics(event_name, *args, **kwargs):
    statsd.incr(f"events.{event_name}")

Because subscribers are just handlers, you can add one in a test, in a debug console, or behind a feature flag.


Patterns and ordering

Subscription Runs for
"user.created" exactly "user.created"
"user.*" "user" and anything below it — "user.created", "user.profile.updated"
"*" every event

Matching is case-sensitive and prefix based: "user.*" does not match "userlike.created" or "admin.user.created".

Dispatch order is deterministic: highest priority first, then registration order. Priority defaults to 0 and may be negative.

bus.on("job.run", audit, priority=10)
bus.on("job.run", work)
bus.on("job.run", cleanup, priority=-10)
# audit → work → cleanup

Wildcard handlers receive the event name first, before the emitted arguments:

@bus.on("user.*")
def handle(event_name, *args, **kwargs):
    ...
bus.emit("user.created", 42)      # handle("user.created", 42)

Handlers added while an event is being dispatched only run on the next emit, and once handlers are removed before they are called. Dispatch always works on a snapshot, so unsubscribing inside a handler is safe.

Error handling

By default a handler exception propagates to whoever emitted the event and the remaining handlers do not run.

try:
    bus.emit("task.started")
except Exception as exc:
    print(f"Event handler failed: {exc}")

Three ways to handle failures:

# 1. Log and keep going (errors are reported, dispatch is not interrupted).
bus = EventBus(on_error=lambda exc, event: logger.exception("event %s", event.name))

# 2. Inspect failures at the call site.
result = bus.emit_collect("task.started")
result.invoked     # handlers called
result.results     # return values of the handlers that succeeded
result.errors      # exceptions raised
result.ok          # True when errors is empty

# 3. Handle them in the handler.
@bus.on("task.started")
def start():
    try:
        ...
    except Exception:
        logger.exception("start failed")

on_error and emit_collect combine: with on_error set, the callback is invoked and dispatch continues; emit_collect still reports the errors.

API reference

Everything below is importable from the package root.

EventBus

from relaykit_tools import EventBus

bus = EventBus()                    # or EventBus(on_error=callback)
Method Description
on(event, handler=None, *, priority=0) Subscribe. Usable as on(event, fn) or @on(event). Returns the handler, so it can decorate.
once(event, handler=None, *, priority=0) Same, but the handler is removed before its first call.
off(event, handler=None) -> bool Remove one handler, or all handlers for that event name/pattern. True if something was removed.
clear(event=None) Remove listeners for one event, or all listeners.
emit(event, *args, **kwargs) -> int Publish; returns the number of handlers invoked.
emit_collect(event, *args, **kwargs) -> DispatchResult Publish, collect results and errors instead of raising.
await emit_async(event, *args, **kwargs) -> int Publish and await coroutine handlers.
await emit_async_collect(event, *args, **kwargs) -> DispatchResult Async collecting variant.
await wait_for(event, *, timeout=None, predicate=None) -> Event Wait for the next matching event.
listener_count(event=None) -> int Listeners in total, or all that would run for event (including patterns).
listeners(event) -> tuple[Handler, ...] The handlers that would run, in dispatch order.
events() -> tuple[str, ...] Exact event names with listeners.
patterns() -> tuple[str, ...] Wildcard patterns with listeners.
scope(prefix) -> ScopedBus Namespaced view.

Event

Returned by wait_for and stored by EventRecorder.

event.name       # "order.paid"
event.args       # (123,)
event.kwargs     # {"total": 99.0}
event.payload    # 123 (first positional argument, or None)
str(event)       # "order.paid(123, total=99.0)"

DispatchResult

Attribute Description
event The emitted Event.
invoked Number of handlers called.
results Return values of handlers that succeeded, in order.
errors Exceptions raised by handlers.
ok True when no handler raised.

ScopedBus

Created with bus.scope(prefix). Supports on, once, off, emit, emit_collect, emit_async, wait_for, nested scope(), close(), and the with statement. Properties: bus, prefix, closed.

scope.off("event") without a handler removes only the listeners that scope registered — it never touches listeners added directly on the bus. Once closed, a scope rejects new subscriptions with RuntimeError.

EventRecorder

recorder = EventRecorder(bus, "user.*", limit=100)
recorder.start()            # also works as a context manager
recorder.stop()
Member Description
events Recorded Event objects, oldest first.
names() Recorded event names.
count(event), last(event=None) Occurrence count / most recent event.
clear() Forget recorded events, keep listening.
assert_emitted(event, times=None) -> Event Raise AssertionError unless it was emitted; returns the last one.
assert_not_emitted(event) Raise AssertionError if it was emitted.

Behaviour and limits

  • Synchronous by default. emit blocks until every handler returns. Async handlers need emit_async.
  • Thread-safe registration and dispatch via a re-entrant lock, so handlers may subscribe, unsubscribe or emit from inside a handler. The lock is not held while handlers run, so a handler may block; the bus itself does not marshal work onto other threads.
  • Async handlers are awaited sequentially (no gather), which keeps ordering predictable. Use your own task group inside a handler if you want concurrency.
  • Event names are case-sensitive and free-form strings (dots recommended).
  • No serialization, retries, persistence or delivery guarantees. This is an in-process bus, not a message broker.
  • No handler return-value aggregation on emit; use emit_collect when you need the results.
  • Callbacks are held strongly while subscribed. Long-lived buses that subscribe short-lived objects (requests, sessions) should use scope.close() or off() to release them.

Examples

Runnable scripts live in examples/:

File Shows
quickstart.py Publishing, decorators, one-shot handlers, wildcards.
plugin_system.py Priorities, namespace patterns, installable plugins, clean teardown.
async_pipeline.py emit_async, wait_for, predicates, error propagation.
testing_events.py Asserting on emitted events with EventRecorder.

From a checkout (no install needed):

PYTHONPATH=src python examples/quickstart.py

Migrating from 0.1.1

The public import path is unchanged; from relaykit_tools import EventBus still works. Behavioural changes and additions in 0.1.2:

  • once handlers are no longer wrapped, so off(event, handler) works with the function you registered, and listener_count is accurate inside handlers.
  • listener_count(event) now counts handlers that would actually run for that event, including matching wildcard listeners.
  • New: priority ordering, namespace patterns ("user.*"), emit_collect, emit_async, wait_for, ScopedBus, EventRecorder, Event, DispatchResult.
  • EventBus.emit still returns an int and still raises handler exceptions by default.

See CHANGELOG.md for the full list.

Development

git clone <your-fork>
cd relaykit
python -m venv .venv
.venv/bin/pip install -e ".[dev]"     # Windows: .venv\Scripts\pip
.venv/bin/python -m pytest

Build and check a release:

python -m build
python -m twine check dist/*

Requirements

  • Python 3.10 or newer.
  • No runtime dependencies.

License

MIT — see LICENSE.

Metadata

Release files for relaykit-tools 0.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for relaykit-tools 0.1.2
File Size Uploaded Provenance
relaykit_tools-0.1.2.tar.gz 24.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for relaykit-tools 0.1.2
File Interpreter ABI Platform
relaykit_tools-0.1.2-py3-none-any.whl Python 3 none any Details

Total release size: 43.1 kB

Release files / relaykit_tools-0.1.2.tar.gz

Download URL relaykit_tools-0.1.2.tar.gz
Size 24.7 kB
Tags Source
SHA-256 checksum
How to use checksums
117d357edc34f0f15a71ce5f81e10e7bcef194a45a25fde7461524d31601b869
BLAKE2b-256 checksum
How to use checksums
03ec066f680c50e3d097aaa809a939ffe1d8046e179f77bb95ddc35b03132316
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.2

Release files / relaykit_tools-0.1.2-py3-none-any.whl

Download URL relaykit_tools-0.1.2-py3-none-any.whl
Size 18.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3803fca8493c24384bce01d4d4065068e243b89de074bbca0b68d0d9591b69d1
BLAKE2b-256 checksum
How to use checksums
d626b23aaa6714fb0902a100478c0ac4e043fcba6e854994c8a3ecb8f649824a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.2

Security for relaykit-tools 0.1.2

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.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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