Skip to content

Signal

Signals are Saffier's event hooks around model lifecycle operations.

They are most commonly used for:

  • side effects around save or delete operations
  • keeping denormalized data in sync
  • integrating logging, auditing, or background work with ORM events

Common built-in lifecycle signals

Model broadcasters expose these built-in hooks:

  • pre_save
  • post_save
  • pre_update
  • post_update
  • pre_delete
  • post_delete

Use the guide page for the narrative walkthrough: Signals

saffier.Signal

Signal()

Minimal async signal dispatcher used by model lifecycle hooks.

Receivers are stored in insertion order and are invoked concurrently when the signal is sent.

Initialize an empty receiver registry for the signal.

Source code in saffier/core/signals/signal.py
31
32
33
34
def __init__(self) -> None:
    """Initialize an empty receiver registry for the signal."""
    self.receivers: dict[int | tuple[int, int], Callable] = {}
    self.receiver_senders: dict[int | tuple[int, int], set[Any] | None] = {}

receivers instance-attribute

receivers = {}

receiver_senders instance-attribute

receiver_senders = {}

connect

connect(receiver, *, sender=None)

Connect one receiver to the signal.

PARAMETER DESCRIPTION
receiver

Callable accepting **kwargs.

TYPE: Callable

sender

Optional sender filter. When provided, the receiver is called only when send uses the same sender value.

TYPE: Any | None DEFAULT: None

RAISES DESCRIPTION
SignalError

If the receiver is not callable or does not accept keyword arguments.

Source code in saffier/core/signals/signal.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def connect(self, receiver: Callable, *, sender: Any | None = None) -> None:
    """Connect one receiver to the signal.

    Args:
        receiver: Callable accepting `**kwargs`.
        sender: Optional sender filter. When provided, the receiver is
            called only when ``send`` uses the same sender value.

    Raises:
        SignalError: If the receiver is not callable or does not accept
            keyword arguments.
    """
    if not callable(receiver):
        raise SignalError("The signals should be callables")

    if not func_accepts_kwargs(receiver):
        raise SignalError("Signal receivers must accept keyword arguments (**kwargs).")

    key = make_id(receiver)
    if key not in self.receivers:
        self.receivers[key] = receiver
        self.receiver_senders[key] = set() if sender is not None else None
    if sender is not None and self.receiver_senders[key] is not None:
        self.receiver_senders[key].add(sender)

connect_via

connect_via(sender)

Return a decorator that connects a receiver for one sender.

The method mirrors Blinker's connect_via ergonomics for migration signals while still using Saffier's own dispatcher. It is intentionally sender-filtered, which lets one global signal handle revision, upgrade, and downgrade receivers independently.

PARAMETER DESCRIPTION
sender

Sender value that must match future send calls.

TYPE: Any

RETURNS DESCRIPTION
Callable[[Callable], Callable]

Callable[[Callable], Callable]: Decorator registering the receiver.

Source code in saffier/core/signals/signal.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def connect_via(self, sender: Any) -> Callable[[Callable], Callable]:
    """Return a decorator that connects a receiver for one sender.

    The method mirrors Blinker's ``connect_via`` ergonomics for migration
    signals while still using Saffier's own dispatcher. It is intentionally
    sender-filtered, which lets one global signal handle ``revision``,
    ``upgrade``, and ``downgrade`` receivers independently.

    Args:
        sender: Sender value that must match future ``send`` calls.

    Returns:
        Callable[[Callable], Callable]: Decorator registering the receiver.
    """

    def wrapper(receiver: Callable) -> Callable:
        self.connect(receiver, sender=sender)
        return receiver

    return wrapper

disconnect

disconnect(receiver)

Disconnect one receiver from the signal.

RETURNS DESCRIPTION
bool

True if a receiver was removed.

TYPE: bool

Source code in saffier/core/signals/signal.py
82
83
84
85
86
87
88
89
90
91
def disconnect(self, receiver: Callable) -> bool:
    """Disconnect one receiver from the signal.

    Returns:
        bool: `True` if a receiver was removed.
    """
    key = make_id(receiver)
    func: Callable | None = self.receivers.pop(key, None)
    self.receiver_senders.pop(key, None)
    return func is not None

send async

send(sender, **kwargs)

Dispatch the signal to all connected receivers concurrently.

PARAMETER DESCRIPTION
sender

Model class, migration command name, or other sender value dispatching the signal.

TYPE: Any

**kwargs

Signal payload forwarded to every receiver.

TYPE: Any DEFAULT: {}

Source code in saffier/core/signals/signal.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
async def send(self, sender: Any, **kwargs: Any) -> None:
    """Dispatch the signal to all connected receivers concurrently.

    Args:
        sender: Model class, migration command name, or other sender value
            dispatching the signal.
        **kwargs: Signal payload forwarded to every receiver.
    """
    receivers = []
    for key, func in self.receivers.items():
        allowed_senders = self.receiver_senders.get(key)
        if allowed_senders is not None and sender not in allowed_senders:
            continue
        result = func(sender=sender, **kwargs)
        if inspect.isawaitable(result):
            receivers.append(result)
    if receivers:
        await asyncio.gather(*receivers)