Events triggered on a child dataclass that is dynamically assigned to a parent dataclass are bubbled up to the parent even if connect_child_events=False is explicitly set on the parent's signal group.
This is true only if parent.events.connect is called before the child is dynamically assigned.
Details
If one evented dataclass contains another evented dataclass, then events bubble up by default. Setting connect_child_events=False on the parent's signal group disables event bubbling.
However, event bubbling is not correctly stopped if ALL of the following apply:
- The parent and child both have their own
SignalGroupDescriptor(),
- The child reference in the parent (
parent.child) is mutated, AND
- The listener (
parent.events.connect) is registered before the child reference is changed.
In other words, this bug does not occur if ANY of the following applies:
- The child does not have its own
SignalGroupDescriptor(),
- The child reference in the parent (
parent.child) is immutable, OR
- The listener (
parent.events.connect) is registered AFTER the child reference is changed.
Reproducible code
from dataclasses import dataclass
from psygnal import SignalGroupDescriptor
@dataclass
class Child:
foo: int = 0
events = SignalGroupDescriptor()
@dataclass
class Parent:
child: Child | None = None
events = SignalGroupDescriptor(connect_child_events=False)
if __name__ == "__main__":
parent = Parent()
parent.events.connect(lambda event: print(f"parent event: {event}"))
child = Child()
parent.child = child
child.foo = 1
Expected output
One line is printed to the console, namely when parent.child is assigned.
Actual output
Two lines are printed: one when parent.child is assigned, and one when child.foo is assigned:
parent event: EmissionInfo(signal=<_DataclassFieldSignalInstance 'child' on <SignalGroup 'ParentSignalGroup' on <Parent instance at 0x23c858774d0> with 1 signals>>, args=(Child(foo=0), None), path=(.child,))
parent event: EmissionInfo(signal=<_DataclassFieldSignalInstance 'foo' on <SignalGroup 'ChildSignalGroup' on <Child instance at 0x23c85877620> with 1 signals>>, args=(1, 0), path=(.child, .foo))
Details (cont.)
Returning to those conditions I mentioned earlier, you can verify them. If you do ANY of the following, you'll find that there is no EmissionInfo(..., path=(.child, .foo), which is the expected behaviour:
- Remove the child's
events = SignalGroupDescriptor(),
- Initialize the parent as
parent = Parent(Child()), remove child = Child() and parent.child = child, and change child.foo = 1 to parent.child.foo = 1, OR
- Move
parent.events.connect(...) to be right before child.foo = 1.
Temporary workaround
In some cases, it may be sufficient to edit your listener(s) to explicitly check the path of the event.
Instead of
parent.events.connect(lambda event: print(f"parent event: {event}"))
do
def parent_listener(event):
if len(event.path) != 1:
return
print(f"parent event: {event}")
parent.events.connect(parent_listener)
You'll have to do this for each listener separately, at all levels of your class hierarchy, which is of course undesirable.
Environment details
Python: v3.14.6
psygnal: v0.15.1
OS: Windows 11 25H2
Events triggered on a child dataclass that is dynamically assigned to a parent dataclass are bubbled up to the parent even if
connect_child_events=Falseis explicitly set on the parent's signal group.This is true only if
parent.events.connectis called before the child is dynamically assigned.Details
If one evented dataclass contains another evented dataclass, then events bubble up by default. Setting
connect_child_events=Falseon the parent's signal group disables event bubbling.However, event bubbling is not correctly stopped if ALL of the following apply:
SignalGroupDescriptor(),parent.child) is mutated, ANDparent.events.connect) is registered before the child reference is changed.In other words, this bug does not occur if ANY of the following applies:
SignalGroupDescriptor(),parent.child) is immutable, ORparent.events.connect) is registered AFTER the child reference is changed.Reproducible code
Expected output
One line is printed to the console, namely when
parent.childis assigned.Actual output
Two lines are printed: one when
parent.childis assigned, and one whenchild.foois assigned:Details (cont.)
Returning to those conditions I mentioned earlier, you can verify them. If you do ANY of the following, you'll find that there is no
EmissionInfo(..., path=(.child, .foo), which is the expected behaviour:events = SignalGroupDescriptor(),parent = Parent(Child()), removechild = Child()andparent.child = child, and changechild.foo = 1toparent.child.foo = 1, ORparent.events.connect(...)to be right beforechild.foo = 1.Temporary workaround
In some cases, it may be sufficient to edit your listener(s) to explicitly check the
pathof theevent.Instead of
do
You'll have to do this for each listener separately, at all levels of your class hierarchy, which is of course undesirable.
Environment details
Python: v3.14.6
psygnal: v0.15.1
OS: Windows 11 25H2