pyapp-kit / psygnal

Python observer pattern (callback/event system). Modeled after Qt Signals & Slots (but independent of Qt)
https://psygnal.readthedocs.io/
BSD 3-Clause "New" or "Revised" License
84 stars 13 forks source link

Specify the name of a single changed signal in EventedModel #261

Closed getzze closed 7 months ago

getzze commented 8 months ago

My problem, I am using attrs to make a dataclass with a name attribute. I want to modify the name before setting it, but I cannot use attrs.converters because they are not bound to the instance. So I create a private _name field and use a name property to validate the name before setting it. Now I would like to emit a signal when name is changed. This is my working example using PR #260.

Now the signal is emitted at self.events._name_changed but I would like it to be self.events.name_changed or self.events.name, i.e. without the "_" prefix because then I will add other signals like self.events.color and I want it to be consistent, so all without the "_" prefix.

Any idea how I can do that without another PR :) ?

from typing import ClassVar
from attrs import define, field
from psygnal import SignalGroupDescriptor, EmissionInfo

@define
class Model:
    events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor(signal_suffix="_changed")

    _controller: str = field()
    _name: str = field(kw_only=True)

    @property
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        # Generate a unique name among siblings
        siblings = [self.controller]
        value = f"{value}_1" if value in siblings else value
        self._name = value

    @property
    def controller(self) -> str:
        return self._controller

m = Model("ctt", name="c")
@m.events.connect
def on_any_change(info: EmissionInfo):
    print(f"field {info.signal.name!r} changed to {info.args}")

m.name = "ctt"; m.name
# >> field '_name_changed' changed to ('ctt_1', 'c')
# I would like it to be 'name_changed'
getzze commented 8 months ago

I managed to make it work with a custom signal_group_class argument. I have to see how it behaves with sub-classing though.

from typing import ClassVar
from attrs import define, field
from psygnal import SignalGroupDescriptor, Signal, SignalGroup, EmissionInfo

ModelSignalGroup = type("ModelSignalGroup", (SignalGroup,), {"name": Signal(str, str)})

@define
class Model:
    events: ClassVar[ModelSignalGroup] = SignalGroupDescriptor(
        signal_group_class= ModelSignalGroup,
    )

    _controller: str = field()
    _name: str = field(kw_only=True)

    @property
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        # Generate a unique name among siblings
        siblings = [self.controller]
        value = f"{value}_1" if value in siblings else value
        self._name = value

    @property
    def controller(self) -> str:
        return self._controller

m = Model("ctt", name="c")
@m.events.connect
def on_any_change(info: EmissionInfo):
    print(f"field {info.signal.name!r} changed to {info.args}")

m.name = "ctt"; m.name
# >>> field 'name' changed to ('ctt_1', 'c')
getzze commented 8 months ago

It works with sub-classing, although I have to redefine the events class attribute for the subclasses.

tlambert03 commented 8 months ago

see #262 and https://github.com/pyapp-kit/psygnal/pull/260#issuecomment-1924217669 for another option.

getzze commented 7 months ago

This can be closed, with #299 merged, it can be done in two ways (that are not exactly equivalent).

With aliases only:

from typing import ClassVar
from attrs import define, field
from psygnal import SignalGroupDescriptor, EmissionInfo

aliases = {"_name": "name", "name": None, "_controller": None}

@define
class Model:
    events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor(signal_aliases=aliases)

    _controller: str = field()
    _name: str = field(kw_only=True)

    @property
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        # Generate a unique name among siblings
        siblings = [self.controller]
        value = f"{value}_1" if value in siblings else value
        self._name = value

    @property
    def controller(self) -> str:
        return self._controller

m = Model("ctt", name="c")
@m.events.connect
def on_any_change(info: EmissionInfo):
    print(f"field {info.signal.name!r} changed to {info.args}")

m.name = "ctt"; m.name
# >> field 'name' changed to ('ctt_1', 'c')

With aliases and a SignalGroup subclass (thanks to #291, it will work on new fields if subclassing Model without the need to redefine events in the subclass):

from typing import ClassVar
from attrs import define, field
from psygnal import SignalGroupDescriptor, Signal, SignalGroup, EmissionInfo

ModelSignalGroup = type("ModelSignalGroup", (SignalGroup,), {"name": Signal(str, str)})

@define
class Model:
    events: ClassVar[ModelSignalGroup] = SignalGroupDescriptor(
        signal_group_class= ModelSignalGroup,
        signal_aliases={"_name": None, "_controller": None}
    )

    _controller: str = field()
    _name: str = field(kw_only=True)

    @property
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        # Generate a unique name among siblings
        siblings = [self.controller]
        value = f"{value}_1" if value in siblings else value
        self._name = value

    @property
    def controller(self) -> str:
        return self._controller

m = Model("ctt", name="c")
@m.events.connect
def on_any_change(info: EmissionInfo):
    print(f"field {info.signal.name!r} changed to {info.args}")

m.name = "ctt"; m.name
# >>> field 'name' changed to ('ctt_1', 'c')