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

fix: avoid name conflicts (option 2) use __get__ directly to create signal instances #262

Closed tlambert03 closed 8 months ago

tlambert03 commented 8 months ago

alternative to #260, for discussion

codspeed-hq[bot] commented 8 months ago

CodSpeed Performance Report

Merging #262 will improve performances by 10.55%

Comparing tlambert03:fix-signal-name-conflict (2a99cbe) with main (d3d558d)

Summary

⚡ 1 improvements ✅ 65 untouched benchmarks

Benchmarks breakdown

Benchmark main tlambert03:fix-signal-name-conflict Change
test_emit_time[partial_method-2] 135.8 µs 122.8 µs +10.55%
getzze commented 8 months ago

Follow up on https://github.com/pyapp-kit/psygnal/pull/260#issuecomment-1924467493 This first idea I had would be too complicated to implement I think because we need to mess with __getattribute__ and it's very error prone. So I was thinking that SignalGroup could be a container class instead.

Positive:

Negative

# Rough implementation, I have to figure out the exact layout because of the descriptors

class SignalAggInstance(SignalInstance):
    def _slot_relay(self, *args: Any) -> None:
        pass

    ...

class SignalAgg(Signal):
    _signal_instance_class: SignalAggInstance

# Container for Signal descriptors
class SignalList:
    pass

class SignalContainer:
    _agg_: ClassVar[SignalAgg]
    _signals_: ClassVar[SignalList]
    _uniform_: ClassVar[bool] = False
    _signal_aliases_: ClassVar[Mapping[str, str | None]]

    def __init_subclass__(
        cls,
        strict: bool = False,
        signal_aliases: Mapping[str, str] = {},
    ) -> None:
        """Finds all Signal instances on the class and add them to `cls._signals_`."""
        all_signals = {}
        for k in dir(cls):
            v = getattr(cls, k)
            if isinstance(v, Signal):
                all_signals[k] = v
                # delete from dir
                delattr(cls, k)

        cls._signals_ = type(f"{cls.__name__}List", (SignalList, ), all_signals)

        cls._uniform_ = _is_uniform(cls._signals_.values())
        if strict and not cls._uniform_:
            raise TypeError(
                "All Signals in a strict SignalGroup must have the same signature"
            )

        cls._signal_aliases_ = {**signal_aliases}

        # Create aggregated signal
        cls._agg_ = Signal(...)

        return super().__init_subclass__()

    def __init__(self):
        self.__agg__attribute_name__ == "agg"
        for k, v in self._signal_aliases_.items():
            if v == "__agg__":
                self.__agg__attribute_name__ == k
                break

    def get_aggregated_signals(self):
        return self._agg_

    def get_signal(self, name: str):
        return getattr(self._signals_, name, None)

    def __getattr__(self, name: str, owner):
        sig = self.get_signal(name)
        if isinstance(sig, SignalInstance):
            return sig
        if name == self.__agg__attribute_name__:
            return self.get_aggregated_signals()
        raise AttributeError

class Events(SignalContainer):
    sig1 = Signal(str)
    sig2 = Signal(str)

events = Events()

def some_callback(record):
    record.signal  # the SignalInstance that emitted
    record.args    # the args that were emitted

events.agg.connect(some_callback)
events.sig1.connect(print)
events.sig2.connect(print)
tlambert03 commented 8 months ago

i can definitely see the merit of deprecating/breaking the pattern of using the SignalGroup itself as a subclass, and instead requiring a more explicit events.agg.connect when you specifically want to listen for any event in the group. I think that could be well worth the trouble, so I'm very open to it.

Given the size of the change, I will want to ping some interested parties. I think napari is moving towards fully swapping out their events with psygnal, and we'll want to involve some of them as well.

So, consider me enthused and intrigued, but will need more time to go through these proposals, summarize them, and get opinions. Thanks again for you all your time!

getzze commented 8 months ago

One thing that could be done as a quick fix also is defining the __set__ method for Signal to make it a data descriptor. Then it gets precedence over normal attributes. Like this it raises an early error so it's easier to understand the problem and debug: https://docs.python.org/3/howto/descriptor.html#descriptor-protocol

class Signal:
    ...

    def __set__(self, instance: Any, value: Any) -> None:
        """Define __set__ to make it a data descriptor.

        A data descriptor takes precedence over normal attributes with the same name.
        """
        raise AttributeError(
            f"Setting SignalInstance {self._name!r} to a new value is not allowed.\n"
            f"Maybe the signal name conflicts with an attribute of {type(instance)}"
        )
    ...
tlambert03 commented 8 months ago

closing this in favor of #269