Notifications¶
Notifications lets a component announce that something happened (a file
arrived, a sensor crossed a threshold, a training run finished, an order came
in) so that others can react without the publisher knowing who they are. Another
component might draft an order change, and a UI might show a toast. Each
message is a Notification: a topic, a payload, and an optional
human-readable summary.
Notifications is the hub, one per Context. A component publishes; any
callable that takes a Notification can subscribe.
Events or Notifications?¶
They look alike, and they are deliberately separate.
Event |
Notification |
|
|---|---|---|
| What it is | A record of a protocol call the framework made: started / completed / failed, with timing |
A message a component publishes about something it did or saw, with a topic and a payload |
| Who emits it | Only the framework (Context) |
Only component code |
| Content | Key, protocol, method, call id, parent, timing, error. Never arguments or results | Topic, payload, an optional summary, level, an optional subject and record |
| Use it to | Observe how the app runs: run state, monitoring, failure alerts | Let others react without the publisher knowing who they are: other components, and people through a UI |
| Not for | Application data | Getting a result back (call the component directly, through DI), or durable messaging across processes (a broker @Service) |
In short: an Event is the framework reporting how it runs your code; a Notification is your code reporting what happened.
They are two hubs with separate subscriptions, context.events and
context.notifications, and neither delivers the other's type. A
notification records the protocol call in progress when it was published as
its parent, so a consumer can relate the two. For example, "published during
task.take-orders' execute()".
Events.notify() is unrelated
Events.notify() is how the framework emits Events around a protocol
call. It has nothing to do with Notifications; to publish one, call
notifications.publish().
Publishing¶
Declare notifications: Notifications in a constructor, or in a
Function's signature. Like Logger, it is
special-cased: each consumer receives a view bound to its own key, so
publish() needs no key:
from opendataframework import Notifications, Task
@Task
class TakeOrders:
def __init__(self, inbox: Inbox, orders: Orders, notifications: Notifications) -> None:
self.inbox = inbox
self.orders = orders
self.notifications = notifications
def execute(self) -> None:
for customer, item, quantity in self.inbox.drain():
order = self.orders.save(Order(None, customer, item, quantity))
self.notifications.publish(
"orders.received",
{"customer": customer, "item": item, "quantity": quantity},
summary=f"{customer}: {quantity} x {item}",
subject=Orders,
record=order.id,
)
publish() delivers the notification to every matching subscriber, writes a
record to the publisher's log, and returns the Notification. The record's
message is notified <topic>: <summary>, and it carries topic,
notification_level, subject, record and parent (the call it was
published during) as fields, but never the payload (see
Log Format). It is written at the log level matching
the notification's level: info and success as INFO, warning as
WARNING, error as ERROR. Set [project.logging] notifications = ... to
choose which are kept (see Levels).
| Argument | Meaning |
|---|---|
topic |
Dotted name of what happened, e.g. "orders.received". Non-empty, with no *. |
payload |
Data for subscribers. Keep it JSON-serialisable, so a UI can show it. Copied, and delivered read-only. |
summary |
One human-readable line. A UI shows only notifications that have one. |
level |
"info" (the default), "success", "warning", or "error". Anything else raises ValueError. |
subject |
The component it's about: a class or function, resolved to its key, or a key string such as "repository.orders". |
record |
Id of the record it's about, within subject. |
key |
The publisher's key. Filled in by an injected view; required on context.notifications. |
Code that holds the Context (a script, a test, the sibling
odf package's UI server) uses the
unbound hub, context.notifications, and passes the key itself:
A subject given as a class or function is resolved through the Context,
so it only works once open() has resolved it. Before that, or for a
component the Context doesn't know, it raises KeyError; pass the key
string instead.
Subscribing¶
unsubscribe = notifications.subscribe(listener) # every notification
unsubscribe = notifications.subscribe(listener, topic="orders.received") # exactly this topic
unsubscribe = notifications.subscribe(listener, topic="orders.*") # by prefix
unsubscribe() # stop; safe to call twice
"orders.*" matches orders.received and orders.received.late, but not
orders itself. A * anywhere other than a trailing .* raises ValueError.
A component subscribes in its constructor, through the same injected view,
and unsubscribes in on_stop():
from collections.abc import Callable
from typing import Annotated
from opendataframework import Component, Notification, Notifications
@Component
class Kitchen:
def __init__(
self,
notifications: Notifications,
schedule: Annotated[Callable[..., str], schedule_bake],
) -> None:
self.schedule = schedule
self._unsubscribe = notifications.subscribe(self.on_order, topic="orders.received")
def on_order(self, notification: Notification) -> None:
self.schedule(order_id=notification.record)
def on_stop(self) -> None:
self._unsubscribe()
See examples/11-notifications/ for a full runnable project
built around this.
The Notification¶
| Field | Meaning |
|---|---|
key |
The publisher's <namespace>.<name> key, e.g. "task.take-orders". |
topic |
e.g. "orders.received". |
payload |
Read-only mapping; {} when none was given. |
summary |
One human-readable line, or None. |
level |
"info", "success", "warning", or "error". |
subject |
Key of the component it's about, or None. |
record |
Id of the record it's about, or None. |
parent |
call_id of the protocol call in progress when it was published (see How parent Nests), or None. |
ts |
time.time() when it was published. |
Delivery¶
Delivery follows the same contract as Events:
- Synchronous. Every listener runs inside
publish(), in the publisher's thread, beforepublish()returns. A slow listener holds up the publisher, so hand slow work to a queue drained by a Service. - Thread-safe listeners. Publishers can be on several threads (a
service's
run(), a stream), so listeners can be called from several threads. - Exceptions are contained. If a listener raises, the exception is logged
as a
WARNING(listener of <topic> raised ...) and never reaches the publisher or other listeners. It is logged on the subscriber's key when the listener subscribed through an injected view, and on the publisher's key when it subscribed throughcontext.notifications. - Listeners run with no current call. A call a listener makes, like
Kitchen'sschedule()above, is top-level (parent=None), not a child of the publisher's call. The reaction is linked to what caused it through the notification's ownparent, not through nesting. - A listener may publish. That publish is delivered right away, before
the outer
publish()moves on to its next listener. Don't have a listener publish a topic it subscribes to.
When Not to Use It¶
- To get a result back. If the publisher needs an answer, it should declare the other component as a dependency and call it. A notification doesn't return anything to its publisher.
- For durable or cross-process messaging. Nothing is stored, retried, or
sent beyond the process. A subscriber that isn't subscribed yet misses the
message. For that, put a broker behind a
@Service. - To observe the framework. Use Events for run state, timings, and failures.
Notifications is per Context too: two Projects in one process never see
each other's notifications.