Skip to content

Dataclass Descriptor#

These objects are used to turn dataclasses into "evented" objects, with a signal emitted whenever a field is changed.

psygnal.SignalGroupDescriptor #

Create a psygnal.SignalGroup on first instance attribute access.

This descriptor is designed to be used as a class attribute on a dataclass-like class (e.g. a dataclass, a pydantic.BaseModel, an attrs class, a msgspec.Struct) On first access of the descriptor on an instance, it will create a SignalGroup bound to the instance, with a SignalInstance for each field in the dataclass.

Important

Using this descriptor will patch the class's __setattr__ method to emit events when fields change. (That patching occurs on first access of the descriptor name on an instance). To prevent this patching, you can set patch_setattr=False when creating the descriptor, but then you will need to manually call emit on the appropriate SignalInstance when you want to emit an event. Or you can use evented_setattr yourself

from psygnal._group_descriptor import evented_setattr
from psygnal import SignalGroupDescriptor
from dataclasses import dataclass
from typing import ClassVar


@dataclass
class Foo:
    x: int
    _events: ClassVar = SignalGroupDescriptor(patch_setattr=False)

    @evented_setattr("_events")  # pass the name of your SignalGroup
    def __setattr__(self, name: str, value: Any) -> None:
        super().__setattr__(name, value)

This currently requires a private import, please open an issue if you would like to depend on this functionality.

Parameters:

  • equality_operators (dict[str, Callable[[Any, Any], bool]], optional) –

    A dictionary mapping field names to custom equality operators, where an equality operator is a callable that accepts two arguments and returns True if the two objects are equal. This will be used when comparing the old and new values of a field to determine whether to emit an event. If not provided, the default equality operator is operator.eq, except for numpy arrays, where np.array_equal is used.

  • warn_on_no_fields (bool, optional) –

    If True (the default), a warning will be emitted if no mutable dataclass-like fields are found on the object.

  • cache_on_instance (bool, optional) –

    If True (the default), a newly-created SignalGroup instance will be cached on the instance itself, so that subsequent accesses to the descriptor will return the same SignalGroup instance. This makes for slightly faster subsequent access, but means that the owner instance will no longer be pickleable. If False, the SignalGroup instance will still be cached, but not on the instance itself.

  • patch_setattr (bool, optional) –

    If True (the default), a new __setattr__ method will be created that emits events when fields change. If False, no __setattr__ method will be created. (This will prevent signal emission, and assumes you are using a different mechanism to emit signals when fields change.)

  • signal_group_class (type[SignalGroup] | None, optional) –

    A custom SignalGroup class to use, SignalGroup if None, by default None

  • collect_fields (bool, optional) –

    Create a signal for each field in the dataclass. If True, the SignalGroup instance will be a subclass of signal_group_class (SignalGroup if it is None). If False, a deepcopy of signal_group_class will be used. Default to True

  • signal_aliases (Mapping[str, str | None] | FieldAliasFunc | None) –

    If defined, a mapping between field name and signal name. Field names that are not signal_aliases keys are not aliased (the signal name is the field name). If the dict value is None, do not create a signal associated with this field. If a callable, the signal name is the output of the function applied to the field name. If the output is None, no signal is created for this field. If None, defaults to an empty dict, no aliases. Default to None

Examples:

from typing import ClassVar
from dataclasses import dataclass
from psygnal import SignalGroupDescriptor


@dataclass
class Person:
    name: str
    age: int = 0
    events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor()


john = Person("John", 40)
john.events.age.connect(print)
john.age += 1  # prints 41
Source code in psygnal/_group_descriptor.py
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
class SignalGroupDescriptor:
    """Create a [`psygnal.SignalGroup`][] on first instance attribute access.

    This descriptor is designed to be used as a class attribute on a dataclass-like
    class (e.g. a [`dataclass`](https://docs.python.org/3/library/dataclasses.html), a
    [`pydantic.BaseModel`](https://docs.pydantic.dev/usage/models/), an
    [attrs](https://www.attrs.org/en/stable/overview.html) class, a
    [`msgspec.Struct`](https://jcristharif.com/msgspec/structs.html)) On first access of
    the descriptor on an instance, it will create a [`SignalGroup`][psygnal.SignalGroup]
    bound to the instance, with a [`SignalInstance`][psygnal.SignalInstance] for each
    field in the dataclass.

    !!!important
        Using this descriptor will *patch* the class's `__setattr__` method to emit
        events when fields change. (That patching occurs on first access of the
        descriptor name on an instance).  To prevent this patching, you can set
        `patch_setattr=False` when creating the descriptor, but then you will need to
        manually call `emit` on the appropriate `SignalInstance` when you want to emit
        an event.  Or you can use `evented_setattr` yourself

        ```python
        from psygnal._group_descriptor import evented_setattr
        from psygnal import SignalGroupDescriptor
        from dataclasses import dataclass
        from typing import ClassVar


        @dataclass
        class Foo:
            x: int
            _events: ClassVar = SignalGroupDescriptor(patch_setattr=False)

            @evented_setattr("_events")  # pass the name of your SignalGroup
            def __setattr__(self, name: str, value: Any) -> None:
                super().__setattr__(name, value)
        ```

        *This currently requires a private import, please open an issue if you would
        like to depend on this functionality.*

    Parameters
    ----------
    equality_operators : dict[str, Callable[[Any, Any], bool]], optional
        A dictionary mapping field names to custom equality operators, where an equality
        operator is a callable that accepts two arguments and returns True if the two
        objects are equal. This will be used when comparing the old and new values of a
        field to determine whether to emit an event. If not provided, the default
        equality operator is `operator.eq`, except for numpy arrays, where
        `np.array_equal` is used.
    warn_on_no_fields : bool, optional
        If `True` (the default), a warning will be emitted if no mutable dataclass-like
        fields are found on the object.
    cache_on_instance : bool, optional
        If `True` (the default), a newly-created SignalGroup instance will be cached on
        the instance itself, so that subsequent accesses to the descriptor will return
        the same SignalGroup instance.  This makes for slightly faster subsequent
        access, but means that the owner instance will no longer be pickleable.  If
        `False`, the SignalGroup instance will *still* be cached, but not on the
        instance itself.
    patch_setattr : bool, optional
        If `True` (the default), a new `__setattr__` method will be created that emits
        events when fields change.  If `False`, no `__setattr__` method will be
        created.  (This will prevent signal emission, and assumes you are using a
        different mechanism to emit signals when fields change.)
    signal_group_class : type[SignalGroup] | None, optional
        A custom SignalGroup class to use, SignalGroup if None, by default None
    collect_fields : bool, optional
        Create a signal for each field in the dataclass. If True, the `SignalGroup`
        instance will be a subclass of `signal_group_class` (SignalGroup if it is None).
        If False, a deepcopy of `signal_group_class` will be used.
        Default to True
    signal_aliases: Mapping[str, str | None] | Callable[[str], str | None] | None
        If defined, a mapping between field name and signal name. Field names that are
        not `signal_aliases` keys are not aliased (the signal name is the field name).
        If the dict value is None, do not create a signal associated with this field.
        If a callable, the signal name is the output of the function applied to the
        field name. If the output is None, no signal is created for this field.
        If None, defaults to an empty dict, no aliases.
        Default to None

    Examples
    --------
    ```python
    from typing import ClassVar
    from dataclasses import dataclass
    from psygnal import SignalGroupDescriptor


    @dataclass
    class Person:
        name: str
        age: int = 0
        events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor()


    john = Person("John", 40)
    john.events.age.connect(print)
    john.age += 1  # prints 41
    ```
    """

    # map of id(obj) -> SignalGroup
    # cached here in case the object isn't modifiable
    _instance_map: ClassVar[dict[int, SignalGroup]] = {}

    def __init__(
        self,
        *,
        equality_operators: dict[str, EqOperator] | None = None,
        warn_on_no_fields: bool = True,
        cache_on_instance: bool = True,
        patch_setattr: bool = True,
        signal_group_class: type[SignalGroup] | None = None,
        collect_fields: bool = True,
        signal_aliases: Mapping[str, str | None] | FieldAliasFunc | None = None,
    ):
        grp_cls = signal_group_class or SignalGroup
        if not (isinstance(grp_cls, type) and issubclass(grp_cls, SignalGroup)):
            raise TypeError(  # pragma: no cover
                f"'signal_group_class' must be a subclass of SignalGroup, "
                f"not {grp_cls}"
            )
        if not collect_fields:
            if grp_cls is SignalGroup:
                raise ValueError(
                    "Cannot use SignalGroup with `collect_fields=False`. "
                    "Use a custom SignalGroup subclass instead."
                )

            if callable(signal_aliases):
                raise ValueError(
                    "Cannot use a Callable for `signal_aliases` with "
                    "`collect_fields=False`"
                )

        self._name: str | None = None
        self._eqop = tuple(equality_operators.items()) if equality_operators else None
        self._warn_on_no_fields = warn_on_no_fields
        self._cache_on_instance = cache_on_instance
        self._patch_setattr = patch_setattr
        self._signal_group_class: type[SignalGroup] = grp_cls
        self._collect_fields = collect_fields
        self._signal_aliases = signal_aliases

        self._signal_groups: dict[int, type[SignalGroup]] = {}

    def __set_name__(self, owner: type, name: str) -> None:
        """Called when this descriptor is added to class `owner` as attribute `name`."""
        self._name = name
        with contextlib.suppress(AttributeError):
            # This is the flag that identifies this object as evented
            setattr(owner, PSYGNAL_GROUP_NAME, name)

    def _do_patch_setattr(self, owner: type, with_aliases: bool = True) -> None:
        """Patch the owner class's __setattr__ method to emit events."""
        if not self._patch_setattr:
            return
        if getattr(owner.__setattr__, PATCHED_BY_PSYGNAL, False):
            return

        name = self._name
        if not (name and hasattr(owner, name)):  # pragma: no cover
            # this should never happen... but if it does, we'll get errors
            # every time we set an attribute on the class.  So raise now.
            raise AttributeError("SignalGroupDescriptor has not been set on the class")

        try:
            # assign a new __setattr__ method to the class
            owner.__setattr__ = evented_setattr(  # type: ignore
                name,
                owner.__setattr__,  # type: ignore
                with_aliases=with_aliases,
            )
        except Exception as e:  # pragma: no cover
            # not sure what might cause this ... but it will have consequences
            raise type(e)(
                f"Could not update __setattr__ on class: {owner}. Events will not be "
                "emitted when fields change."
            ) from e

    @overload
    def __get__(self, instance: None, owner: type) -> SignalGroupDescriptor: ...

    @overload
    def __get__(self, instance: object, owner: type) -> SignalGroup: ...

    def __get__(
        self, instance: object, owner: type
    ) -> SignalGroup | SignalGroupDescriptor:
        """Return a SignalGroup instance for `instance`."""
        if instance is None:
            return self

        signal_group = self._get_signal_group(owner)

        # if we haven't yet instantiated a SignalGroup for this instance,
        # do it now and cache it.  Note that we cache it here in addition to
        # the instance (in case the instance is not modifiable).
        obj_id = id(instance)
        if obj_id not in self._instance_map:
            # cache it
            self._instance_map[obj_id] = signal_group(instance)
            # also *try* to set it on the instance as well, since it will skip all the
            # __get__ logic in the future, but if it fails, no big deal.
            if self._name and self._cache_on_instance:
                with contextlib.suppress(Exception):
                    setattr(instance, self._name, self._instance_map[obj_id])

            # clean up the cache when the instance is deleted
            with contextlib.suppress(TypeError):  # if it's not weakref-able
                weakref.finalize(instance, self._instance_map.pop, obj_id, None)

        return self._instance_map[obj_id]

    def _get_signal_group(self, owner: type) -> type[SignalGroup]:
        type_id = id(owner)
        if type_id not in self._signal_groups:
            self._signal_groups[type_id] = self._create_group(owner)
        return self._signal_groups[type_id]

    def _create_group(self, owner: type) -> type[SignalGroup]:
        if not self._collect_fields:
            # Do not collect fields from owner class
            Group = copy.deepcopy(self._signal_group_class)

            # Add aliases
            if isinstance(self._signal_aliases, dict):
                Group._psygnal_aliases.update(self._signal_aliases)

        else:
            # Collect fields and create SignalGroup subclass
            Group = _build_dataclass_signal_group(
                owner,
                self._signal_group_class,
                equality_operators=self._eqop,
                signal_aliases=self._signal_aliases,
            )

        if self._warn_on_no_fields and not Group._psygnal_signals:
            warnings.warn(
                f"No mutable fields found on class {owner}: no events will be "
                "emitted. (Is this a dataclass, attrs, msgspec, or pydantic model?)",
                stacklevel=2,
            )

        self._do_patch_setattr(owner, with_aliases=bool(Group._psygnal_aliases))
        return Group

psygnal.evented(cls=None, *, events_namespace='events', equality_operators=None, warn_on_no_fields=True, cache_on_instance=True, signal_aliases=None) #

A decorator to add events to a dataclass.

See also the documentation for SignalGroupDescriptor. This decorator is equivalent setting a class variable named events to a new SignalGroupDescriptor instance.

Note that this decorator will modify cls in place, as well as return it.

Tip

It is recommended to use the SignalGroupDescriptor descriptor rather than the decorator, as it it is more explicit and provides for easier static type inference.

Parameters:

  • cls (type) –

    The class to decorate.

  • events_namespace (str) –

    The name of the namespace to add the events to, by default "events"

  • equality_operators (dict[str, Callable] | None) –

    A dictionary mapping field names to equality operators (a function that takes two values and returns True if they are equal). These will be used to determine if a field has changed when setting a new value. By default, this will use the __eq__ method of the field type, or np.array_equal, for numpy arrays. But you can provide your own if you want to customize how equality is checked. Alternatively, if the class has an __eq_operators__ class attribute, it will be used.

  • warn_on_no_fields (bool) –

    If True (the default), a warning will be emitted if no mutable dataclass-like fields are found on the object.

  • cache_on_instance (bool, optional) –

    If True (the default), a newly-created SignalGroup instance will be cached on the instance itself, so that subsequent accesses to the descriptor will return the same SignalGroup instance. This makes for slightly faster subsequent access, but means that the owner instance will no longer be pickleable. If False, the SignalGroup instance will still be cached, but not on the instance itself.

  • signal_aliases (Mapping[str, str | None] | FieldAliasFunc | None) –

    If defined, a mapping between field name and signal name. Field names that are not signal_aliases keys are not aliased (the signal name is the field name). If the dict value is None, do not create a signal associated with this field. If a callable, the signal name is the output of the function applied to the field name. If the output is None, no signal is created for this field. If None, defaults to an empty dict, no aliases. Default to None

Returns:

  • type

    The decorated class, which gains a new SignalGroup instance at the events_namespace attribute (by default, events).

Raises:

  • TypeError

    If the class is frozen or is not a class.

Examples:

from psygnal import evented
from dataclasses import dataclass


@evented
@dataclass
class Person:
    name: str
    age: int = 0
Source code in psygnal/_evented_decorator.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def evented(
    cls: T | None = None,
    *,
    events_namespace: str = "events",
    equality_operators: dict[str, EqOperator] | None = None,
    warn_on_no_fields: bool = True,
    cache_on_instance: bool = True,
    signal_aliases: Mapping[str, str | None] | FieldAliasFunc | None = None,
) -> Callable[[T], T] | T:
    """A decorator to add events to a dataclass.

    See also the documentation for
    [`SignalGroupDescriptor`][psygnal.SignalGroupDescriptor].  This decorator is
    equivalent setting a class variable named `events` to a new
    `SignalGroupDescriptor` instance.

    Note that this decorator will modify `cls` *in place*, as well as return it.

    !!!tip
        It is recommended to use the `SignalGroupDescriptor` descriptor rather than
        the decorator, as it it is more explicit and provides for easier static type
        inference.

    Parameters
    ----------
    cls : type
        The class to decorate.
    events_namespace : str
        The name of the namespace to add the events to, by default `"events"`
    equality_operators : dict[str, Callable] | None
        A dictionary mapping field names to equality operators (a function that takes
        two values and returns `True` if they are equal). These will be used to
        determine if a field has changed when setting a new value.  By default, this
        will use the `__eq__` method of the field type, or np.array_equal, for numpy
        arrays.  But you can provide your own if you want to customize how equality is
        checked. Alternatively, if the class has an `__eq_operators__` class attribute,
        it will be used.
    warn_on_no_fields : bool
        If `True` (the default), a warning will be emitted if no mutable dataclass-like
        fields are found on the object.
    cache_on_instance : bool, optional
        If `True` (the default), a newly-created SignalGroup instance will be cached on
        the instance itself, so that subsequent accesses to the descriptor will return
        the same SignalGroup instance.  This makes for slightly faster subsequent
        access, but means that the owner instance will no longer be pickleable.  If
        `False`, the SignalGroup instance will *still* be cached, but not on the
        instance itself.
    signal_aliases: Mapping[str, str | None] | Callable[[str], str | None] | None
        If defined, a mapping between field name and signal name. Field names that are
        not `signal_aliases` keys are not aliased (the signal name is the field name).
        If the dict value is None, do not create a signal associated with this field.
        If a callable, the signal name is the output of the function applied to the
        field name. If the output is None, no signal is created for this field.
        If None, defaults to an empty dict, no aliases.
        Default to None

    Returns
    -------
    type
        The decorated class, which gains a new SignalGroup instance at the
        `events_namespace` attribute (by default, `events`).

    Raises
    ------
    TypeError
        If the class is frozen or is not a class.

    Examples
    --------
    ```python
    from psygnal import evented
    from dataclasses import dataclass


    @evented
    @dataclass
    class Person:
        name: str
        age: int = 0
    ```
    """

    def _decorate(cls: T) -> T:
        if not isinstance(cls, type):  # pragma: no cover
            raise TypeError("evented can only be used on classes")
        if any(k.startswith("_psygnal") for k in getattr(cls, "__annotations__", {})):
            raise TypeError("Fields on an evented class cannot start with '_psygnal'")

        descriptor: SignalGroupDescriptor = SignalGroupDescriptor(
            equality_operators=equality_operators,
            warn_on_no_fields=warn_on_no_fields,
            cache_on_instance=cache_on_instance,
            signal_aliases=signal_aliases,
        )
        # as a decorator, this will have already been called
        descriptor.__set_name__(cls, events_namespace)
        setattr(cls, events_namespace, descriptor)
        return cls

    return _decorate(cls) if cls is not None else _decorate