Evented Dataclasses#
A common usage of signals in an application is to notify other parts of the application when a particular object or value has changed. More often than not, these values are attributes on some object. Dataclasses are a very common way to represent such objects, and psygnal
provides a convenient way to add the observer pattern to any dataclass.
What is a dataclass#
A "data class" is a class that is primarily used to store a set of data. Of course, most python data structures (e.g. tuples
, dicts
) are used to store a set of data, but when we refer to a "data class" we are generally referring to a class that formally defines a set of fields or attributes, each with a name, a current value, and (preferably) a type. The values could represent anything, such as a configuration of some sort, a set of parameters, or a set of data that is being processed.
... in the standard library#
Python 3.7 introduced the dataclasses
module, which provides a decorator that can be used to easily create such classes with a minimal amount of boilerplate. For example:
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int = 0
john = Person(name="John", age=30)
print(john) # prints: Person(name='John', age=30)
Hint
There's a lot more to be learned about dataclasses! See the python docs for more, and this realpython blog post for an in-depth introduction.
... in third-party libraries#
There are multiple third-party libraries that also implement this pattern, or something similar:
- pydantic provides a
BaseModel
class, a dataclass-like object that can additionally perform type validation and facilitates serialization to and from JSON. - msgspec provides a
Struct
class that is extremely fast and lightweight, with an emphasis on serialization. - the
attrs
library provides the@define
decorator, which is similar to the@dataclass
decorator, but with a few additional features.
All of these libraries are still in common use, and each has its own strengths and weaknesses (discussed in depth elsewhere).
The observer pattern#
The observer pattern is a software design pattern in which an object, named the subject, maintains a list of its dependents, called observers, and notifies them automatically of any state changes, usually by calling a callback function provided by the observer.
psygnal implements the observer pattern.
Here is a simple example of a class that uses psygnal
to notify other parts of the application when its age
attribute changes:
from psygnal import Signal
class Person:
age_changed: Signal(int)
@property
def age(self):
return self._age
@age.setter
def age(self, value):
self._age = value
self.age_changed(value)
# create an instance of the class
john = Person()
# now we can connect a callback to the `age_changed` signal
def my_callback(age: int):
print(f"John's age changed to {age}.")
john.age_changed.connect(my_callback)
But there's a lot of boilerplate here. We have to define a signal and create a setter method that emits that signal (for each field!). This is where psygnal
's dataclass support comes in handy.
Adding the observer pattern to any dataclass using Psygnal#
psygnal
provides the ability to make a dataclass "evented", meaning that any time a field value is changed, a signal will be emitted. psygnal's SignalGroupDescriptor
does this by:
- Inspecting the object to determine what the (mutable) fields are (psygnal has awareness of multiple dataclass libraries, including the standard library's
dataclasses
module) - Creating a
SignalGroup
with aSignalInstance
for each field name - Adding the
SignalGroup
as a new attribute on the object
A signal will be then emitted whenever the field value is changed, with the new value as the first argument.
There are two (related) APIs for adding events to dataclasses:
-
Add a
SignalGroupDescriptor
as a class attribute.Example
from typing import ClassVar from psygnal import SignalGroupDescriptor from dataclasses import dataclass @dataclass class Person: name: str age: int = 0 events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor()
from typing import ClassVar from psygnal import SignalGroupDescriptor from pydantic import BaseModel class Person(BaseModel): name: str age: int = 0 events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor()
for a fully evented subclass of pydantic's
BaseModel
, see alsoEventedModel
from typing import ClassVar from psygnal import SignalGroupDescriptor import msgspec class Person(msgspec.Struct): name: str age: int = 0 events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor()
from typing import ClassVar from psygnal import SignalGroupDescriptor from attrs import define @define class Person: name: str age: int = 0 events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor()
-
Decorate the class with the
@evented
decorator.Under the hood, this just adds the
SignalGroupDescriptor
as a class attribute named "events" for you, as shown above). Prefer the class attribute pattern to the decorator when in doubt.Example
from psygnal import evented from dataclasses import dataclass @evented @dataclass class Person: name: str age: int = 0
from psygnal import evented from pydantic import BaseModel @evented class Person(BaseModel): name: str age: int = 0
for a fully evented subclass of pydantic's
BaseModel
, see alsoEventedModel
from psygnal import evented import msgspec @evented class Person(msgspec.Struct): name: str age: int = 0
from psygnal import evented from attrs import define @evented @define class Person: name: str age: int = 0
Tip
by default, the
SignalGroup
instance is named'events'
, but this can be changed by passing aevents_namespace
argument to the@evented
decorator)
Using any of the above, you can now connect callbacks to the change events of any field on the object (there will be a signal instance in the events
attribute for each mutable field in your dataclass)
# create an instance of the dataclass
john = Person(name="John", age=30)
# now we can connect a callback to any event on the `events` namespace
@john.events.age.connect
def on_age_changed(age: int):
print(f"John's age changed to {age}.")
# change a value
john.age = 31 # prints: John's age changed to 31.
You can also connect to the SignalGroup
itself to listen to any changes on the object:
from psygnal import EmissionInfo
@john.events.connect
def on_any_change(info: EmissionInfo):
print(f"field {info.signal.name!r} changed to {info.args}")
see the API documentation for for more details.
Type annotating evented dataclasses#
If you use the SignalGroupDescriptor
API, it is easier for type checkers because you are explicitly providing the events
namespace for the SignalGroup.
By default, type checkers and IDEs will not know about the signals that are dynamically added to the class by the @evented
decorator. If you'd like to have your type checker or IDE know about the signals, you can add an annotation as follows:
from psygnal import evented
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from psygnal import SignalGroup
class PersonSignalGroup(SignalGroup):
name: SignalInstance
age: SignalInstance
@evented
@dataclass
class Person:
# just one of a few ways to annotate the `events` namespace
if TYPE_CHECKING:
events: PersonSignalGroup
name: str
age: int = 0
Note
I know... it's not awesome :/
Remember that adding these type annotations is optional: signals will still work without them. But your IDE will not know that Person has an events
attribute, and it will not know that events.name
is a SignalInstance
.
If you have any ideas for how to improve this, please let me know!
Performance cost of evented dataclasses#
Adding signal emission on every field change is definitely not without cost, as it requires 2 additional getattr
calls and an equality check for every field change.
The scale of the penalty will depend on the flavor of dataclass you are using, with fast dataclasses like msgspec
taking a much bigger hit than slower ones.
The following table shows the minimum time it took (on my computer) to set an attribute on a dataclass, with and without signal emission. (Timed using timeit
, with 20 repeats of 100,000 iterations each).
dataclass | without signals | with signals | penalty (fold slower) |
---|---|---|---|
pydantic v1 | 0.386 µs | 0.902 µs | 2.33 |
pydantic v2 | 1.533 µs | 2.145 µs | 1.39 |
dataclasses | 0.015 µs | 0.371 µs | 24.55 |
msgspec | 0.026 µs | 0.561 µs | 21.85 |
attrs | 0.014 µs | 0.540 µs | 37.85 |