From 4da17b7c53d6fe31151f8c5957078dad4186bc48 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 30 Mar 2024 21:12:55 -0400 Subject: [PATCH 01/16] proof of principle --- src/psygnal/_group.py | 2 +- src/psygnal/_signal.py | 87 +++++++++++++++++++++++++++++++++--------- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index f50c2483..b69ff0ae 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -475,7 +475,7 @@ def connect( max_args: int | None = None, on_ref_error: RefErrorChoice = "warn", priority: int = 0, - ) -> Callable[[F], F] | F: + ) -> Callable: if slot is None: return self._psygnal_relay.connect( thread=thread, diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 53b881b5..64d49185 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -143,12 +143,14 @@ def ensure_at_least_20(val: int): ClassVar, ContextManager, Final, + Generic, Iterable, Iterator, Literal, NoReturn, Type, TypeVar, + TypeVarTuple, Union, cast, get_args, @@ -180,6 +182,12 @@ def ensure_at_least_20(val: int): ReducerFunc = Union[ReducerOneArg, ReducerTwoArgs] +T1 = TypeVar("T1") +T2 = TypeVar("T2") +T3 = TypeVar("T3") +T4 = TypeVar("T4") +Ts = TypeVarTuple("Ts") + __all__ = ["Signal", "SignalInstance", "_compiled"] _NULL = object() @@ -215,7 +223,7 @@ def _members() -> set[str]: return VALID_REEMISSION -class Signal: +class Signal(Generic[*Ts]): """Declares a signal emitter on a class. This is class implements the [descriptor @@ -290,7 +298,7 @@ class attribute that is bound to the signal will be used. default None def __init__( self, - *types: type[Any] | Signature, + *types: *Ts, description: str = "", name: str | None = None, check_nargs_on_connect: bool = True, @@ -310,7 +318,7 @@ def __init__( if len(types) > 1: warnings.warn( "Only a single argument is accepted when directly providing a" - f" `Signature`. These args were ignored: {types[1:]}", + f" `Signature`. These args were ignored: {types[1:]}", # type: ignore stacklevel=2, ) else: @@ -327,16 +335,18 @@ def __set_name__(self, owner: type[Any], name: str) -> None: self._name = name @overload - def __get__(self, instance: None, owner: type[Any] | None = None) -> Signal: ... + def __get__( + self, instance: None, owner: type[Any] | None = None + ) -> Signal[*Ts]: ... @overload def __get__( self, instance: Any, owner: type[Any] | None = None - ) -> SignalInstance: ... + ) -> SignalInstance[*Ts]: ... def __get__( self, instance: Any, owner: type[Any] | None = None - ) -> Signal | SignalInstance: + ) -> Signal[*Ts] | SignalInstance[*Ts]: """Get signal instance. This is called when accessing a Signal instance. If accessed as an @@ -390,7 +400,7 @@ def _cache_signal_instance( def _create_signal_instance( self, instance: Any, name: str | None = None - ) -> SignalInstance: + ) -> SignalInstance[*Ts]: return self._signal_instance_class( self.signature, instance=instance, @@ -441,7 +451,7 @@ def sender(cls) -> Any: @mypyc_attr(allow_interpreted_subclasses=True) -class SignalInstance: +class SignalInstance(Generic[*Ts]): """A signal instance (optionally) bound to an object. In most cases, users will not create a `SignalInstance` directly -- instead @@ -505,7 +515,7 @@ class Emitter: def __init__( self, - signature: Signature | tuple = _empty_signature, + signature: tuple | Signature = _empty_signature, *, instance: Any = None, name: str | None = None, @@ -590,11 +600,52 @@ def connect( on_ref_error: RefErrorChoice = ..., priority: int = ..., ) -> Callable[[F], F]: ... - @overload def connect( - self, - slot: F, + self: SignalInstance[()], + slot: Callable[[], Any], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[], Any]: ... + @overload + def connect( + self: SignalInstance[type[T1]], + slot: Callable[[], Any] | Callable[[T1], Any], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[], Any]: ... + @overload + def connect( + self: SignalInstance[type[T1], type[T2]], + slot: Callable[[], Any] | Callable[[T1], Any] | Callable[[T1, T2], Any], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[], Any]: ... + @overload + def connect( + self: SignalInstance[type[T1], type[T2], type[T3]], + slot: Callable[[], Any] + | Callable[[T1], Any] + | Callable[[T1, T2], Any] + | Callable[[T1, T2, T3], Any], *, thread: threading.Thread | Literal["main", "current"] | None = ..., check_nargs: bool | None = ..., @@ -603,11 +654,11 @@ def connect( max_args: int | None = None, on_ref_error: RefErrorChoice = ..., priority: int = ..., - ) -> F: ... + ) -> Callable[[], Any]: ... def connect( self, - slot: F | None = None, + slot: Callable | None = None, *, thread: threading.Thread | Literal["main", "current"] | None = None, check_nargs: bool | None = None, @@ -616,7 +667,7 @@ def connect( max_args: int | None = None, on_ref_error: RefErrorChoice = "warn", priority: int = 0, - ) -> Callable[[F], F] | F: + ) -> Callable: """Connect a callback (`slot`) to this signal. `slot` is compatible if: @@ -701,10 +752,10 @@ def my_function(): ... check_types = self._check_types_on_connect def _wrapper( - slot: F, + slot: Callable, max_args: int | None = max_args, _on_ref_err: RefErrorChoice = on_ref_error, - ) -> F: + ) -> Callable: if not callable(slot): raise TypeError(f"Cannot connect to non-callable object: {slot}") @@ -740,7 +791,7 @@ def _wrapper( self._append_slot(cb) return slot - return _wrapper if slot is None else _wrapper(slot) + return _wrapper if slot is None else _wrapper(slot) # type: ignore def _append_slot(self, slot: WeakCallback) -> None: """Append a slot to the list of slots. From 3d15e29e80bc6ac8497cf32f8a5da624b561457e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 30 Mar 2024 21:33:07 -0400 Subject: [PATCH 02/16] fixing type tests --- src/psygnal/_signal.py | 4 ++-- typesafety/test_signal.yml | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 64d49185..98423b0d 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -515,7 +515,7 @@ class Emitter: def __init__( self, - signature: tuple | Signature = _empty_signature, + signature: tuple[*Ts] | Signature = _empty_signature, *, instance: Any = None, name: str | None = None, @@ -1562,7 +1562,7 @@ def _stub_sig(obj: Any) -> Signature: raise ValueError("unknown object") -def _build_signature(*types: type[Any]) -> Signature: +def _build_signature(*types: Any) -> Signature: params = [ Parameter(name=f"p{i}", kind=Parameter.POSITIONAL_ONLY, annotation=t) for i, t in enumerate(types) diff --git a/typesafety/test_signal.yml b/typesafety/test_signal.yml index 0f19f704..ca6a8f74 100644 --- a/typesafety/test_signal.yml +++ b/typesafety/test_signal.yml @@ -6,18 +6,17 @@ s = Signal() t = T() - reveal_type(T.s) # N: Revealed type is "psygnal._signal.Signal" - reveal_type(t.s) # N: Revealed type is "psygnal._signal.SignalInstance" + reveal_type(T.s) # N: Revealed type is "psygnal._signal.Signal[()]" + reveal_type(t.s) # N: Revealed type is "psygnal._signal.SignalInstance[()]" - case: signal_params main: | from psygnal import Signal from inspect import Signature s = Signal() - s = Signal(int, str) - s = Signal(object) - s = Signal(Signature()) - s = Signal(1) # ER: Argument 1 to "Signal" has incompatible type "int"; .* + s2 = Signal(int, str) + s3 = Signal(object) + s4 = Signal(Signature()) - case: signal_connection main: | From 53c028a5d8545c81688fe6fa5817e29153f3b931 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 30 Mar 2024 23:11:53 -0400 Subject: [PATCH 03/16] working --- src/psygnal/_group.py | 12 +++++--- src/psygnal/_signal.py | 65 ++++++++++++++++++++++++++++++--------- tests/test_group.py | 2 +- typesafety/test_group.yml | 3 +- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index b69ff0ae..eac45001 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -32,10 +32,14 @@ if TYPE_CHECKING: import threading + from typing import TypeVarTuple from psygnal._signal import F, ReducerFunc from psygnal._weak_callback import RefErrorChoice, WeakCallback + Ts = TypeVarTuple("Ts") + + __all__ = ["EmissionInfo", "SignalGroup"] @@ -52,7 +56,7 @@ class EmissionInfo(NamedTuple): args: tuple[Any, ...] -class SignalRelay(SignalInstance): +class SignalRelay(SignalInstance[type[EmissionInfo]]): """Special SignalInstance that can be used to connect to all signals in a group. This class will rarely be instantiated by a user (or anything other than a @@ -381,7 +385,7 @@ def __len__(self) -> int: """Return the number of signals in the group (not including the relay).""" return len(self._psygnal_instances) - def __getitem__(self, item: str) -> SignalInstance: + def __getitem__(self, item: str) -> SignalInstance[*Ts]: """Get a signal instance by name.""" return self._psygnal_instances[item] @@ -389,7 +393,7 @@ def __getitem__(self, item: str) -> SignalInstance: # where the SignalGroup comes from the SignalGroupDescriptor # (such as in evented dataclasses). In those cases, it's hard to indicate # to mypy that all remaining attributes are SignalInstances. - def __getattr__(self, __name: str) -> SignalInstance: + def __getattr__(self, __name: str) -> SignalInstance[*Ts]: """Get a signal instance by name.""" raise AttributeError( # pragma: no cover f"{type(self).__name__!r} object has no attribute {__name!r}" @@ -466,7 +470,7 @@ def connect( def connect( self, - slot: F | None = None, + slot: Callable | None = None, *, thread: threading.Thread | Literal["main", "current"] | None = None, check_nargs: bool | None = None, diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 98423b0d..a62d976e 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -513,6 +513,27 @@ class Emitter: _is_paused: bool = False _debug_hook: ClassVar[Callable[[EmissionInfo], None] | None] = None + @overload + def __init__( + self: SignalInstance[()], + *, + instance: Any = None, + name: str | None = None, + check_nargs_on_connect: bool = True, + check_types_on_connect: bool = False, + reemission: ReemissionVal = DEFAULT_REEMISSION, + ) -> None: ... + @overload + def __init__( + self, + signature: tuple[*Ts] | Signature = _empty_signature, + *, + instance: Any = None, + name: str | None = None, + check_nargs_on_connect: bool = True, + check_types_on_connect: bool = False, + reemission: ReemissionVal = DEFAULT_REEMISSION, + ) -> None: ... def __init__( self, signature: tuple[*Ts] | Signature = _empty_signature, @@ -588,18 +609,6 @@ def __repr__(self) -> str: instance = f" on {self.instance!r}" if self.instance is not None else "" return f"<{type(self).__name__}{name}{instance}>" - @overload - def connect( - self, - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[F], F]: ... @overload def connect( self: SignalInstance[()], @@ -655,7 +664,33 @@ def connect( on_ref_error: RefErrorChoice = ..., priority: int = ..., ) -> Callable[[], Any]: ... - + # fallback overload for unparameterized version + # takes any function and returns it + @overload + def connect( + self, + slot: F, + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> F: ... + @overload # decorator version with no parameters + def connect( + self, + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[F], F]: ... def connect( self, slot: Callable | None = None, @@ -752,10 +787,10 @@ def my_function(): ... check_types = self._check_types_on_connect def _wrapper( - slot: Callable, + slot: F, max_args: int | None = max_args, _on_ref_err: RefErrorChoice = on_ref_error, - ) -> Callable: + ) -> F: if not callable(slot): raise TypeError(f"Cannot connect to non-callable object: {slot}") diff --git a/tests/test_group.py b/tests/test_group.py index 9098785a..abac651e 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -433,7 +433,7 @@ def test_group_relay_signatures() -> None: group_sig = signature(getattr(SignalGroup, name)) relay_sig = signature(getattr(SignalRelay, name)) - assert group_sig == relay_sig + assert group_sig == relay_sig, f"{name}: {group_sig} != {relay_sig}" def test_group_relay_passthrough() -> None: diff --git a/typesafety/test_group.yml b/typesafety/test_group.yml index c8815c51..c0ae15ff 100644 --- a/typesafety/test_group.yml +++ b/typesafety/test_group.yml @@ -12,8 +12,7 @@ t = T() reveal_type(T.e) # N: Revealed type is "psygnal._group_descriptor.SignalGroupDescriptor" reveal_type(t.e) # N: Revealed type is "psygnal._group.SignalGroup" - reveal_type(t.e['x']) # N: Revealed type is "psygnal._signal.SignalInstance" - reveal_type(t.e.x) # N: Revealed type is "psygnal._signal.SignalInstance" + reveal_type(t.e.x) # N: Revealed type is "psygnal._signal.SignalInstance[Unpack[Ts`-1]]" @t.e['x'].connect def func(x: int) -> None: From 375fc5743f6dd843f2a385b88bb040911756bd68 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 31 Mar 2024 22:09:42 -0400 Subject: [PATCH 04/16] more type safety --- .github/workflows/test.yml | 11 +- scripts/render_connect_overloads.py | 74 ++ src/psygnal/_group.py | 14 +- src/psygnal/_signal.py | 332 ++++-- src/psygnal/_signal.py.jinja2 | 1711 +++++++++++++++++++++++++++ tests/test_psygnal.py | 11 +- typesafety/test_group.yml | 2 +- typesafety/test_signal.yml | 41 +- 8 files changed, 2112 insertions(+), 84 deletions(-) create mode 100644 scripts/render_connect_overloads.py create mode 100644 src/psygnal/_signal.py.jinja2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dbaaec18..67fd35c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: pipx run check-manifest + - run: | + pipx run check-manifest + + check-templates: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + pip install jinja2 + CHECK_JINJA=1 python scripts/generate_select.py test: name: Test diff --git a/scripts/render_connect_overloads.py b/scripts/render_connect_overloads.py new file mode 100644 index 00000000..6fe9ff31 --- /dev/null +++ b/scripts/render_connect_overloads.py @@ -0,0 +1,74 @@ +"""Render @overload for SignalInstance.connect.""" + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from tempfile import NamedTemporaryFile + +from jinja2 import Template + +ROOT = Path(__file__).parent.parent / "src" / "psygnal" +TEMPLATE_PATH = ROOT / "_signal.py.jinja2" +DEST_PATH = TEMPLATE_PATH.with_suffix("") + +# Maximum number of arguments allowed in callbacks +MAX_ARGS = 5 + + +@dataclass +class Arg: + """Single arg.""" + + name: str + hint: str + default: str | None = None + + +@dataclass +class Sig: + """Full signature.""" + + arguments: list[Arg] + return_hint: str + + +connect_overloads: list[Sig] = [] +for nself in range(MAX_ARGS + 1): + for ncallback in range(nself + 1): + if nself: + self_types = ", ".join(f"type[_T{i+1}]" for i in range(nself)) + else: + self_types = "()" + arg_types = ", ".join(f"_T{i+1}" for i in range(ncallback)) + slot_type = f"Callable[[{arg_types}], RetT]" + connect_overloads.append( + Sig( + arguments=[ + Arg(name="self", hint=f"SignalInstance[{self_types}]"), + Arg(name="slot", hint=slot_type), + ], + return_hint=slot_type, + ) + ) + +template: Template = Template(TEMPLATE_PATH.read_text()) +result = template.render(number_of_types=MAX_ARGS, connect_overloads=connect_overloads) + +result = ( + "# WARNING: do not modify this code, it is generated by " + f"{TEMPLATE_PATH.name}\n\n" + result +) + +# make a temporary file to write to +with NamedTemporaryFile(suffix=".py") as tmp: + Path(tmp.name).write_text(result) + subprocess.run(["ruff", "format", tmp.name]) # noqa + subprocess.run(["ruff", "check", tmp.name, "--fix"]) # noqa + result = Path(tmp.name).read_text() + +current_content = DEST_PATH.read_text() if DEST_PATH.exists() else "" +if current_content != result and os.getenv("CHECK_JINJA"): + raise RuntimeError(f"{DEST_PATH} content not up to date with {TEMPLATE_PATH.name}") + +DEST_PATH.write_text(result) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index eac45001..61a936e9 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -26,7 +26,13 @@ overload, ) -from psygnal._signal import _NULL, Signal, SignalInstance, _SignalBlocker +from psygnal._signal import ( + _NULL, + GroupSignalInstance, + Signal, + SignalInstance, + _SignalBlocker, +) from ._mypyc import mypyc_attr @@ -73,7 +79,7 @@ class SignalRelay(SignalInstance[type[EmissionInfo]]): def __init__( self, signals: Mapping[str, SignalInstance], instance: Any = None ) -> None: - super().__init__(signature=(EmissionInfo,), instance=instance) + super().__init__((EmissionInfo,), instance=instance) self._signals = MappingProxyType(signals) self._sig_was_blocked: dict[str, bool] = {} @@ -385,7 +391,7 @@ def __len__(self) -> int: """Return the number of signals in the group (not including the relay).""" return len(self._psygnal_instances) - def __getitem__(self, item: str) -> SignalInstance[*Ts]: + def __getitem__(self, item: str) -> SignalInstance[GroupSignalInstance]: """Get a signal instance by name.""" return self._psygnal_instances[item] @@ -393,7 +399,7 @@ def __getitem__(self, item: str) -> SignalInstance[*Ts]: # where the SignalGroup comes from the SignalGroupDescriptor # (such as in evented dataclasses). In those cases, it's hard to indicate # to mypy that all remaining attributes are SignalInstances. - def __getattr__(self, __name: str) -> SignalInstance[*Ts]: + def __getattr__(self, __name: str) -> SignalInstance[GroupSignalInstance]: """Get a signal instance by name.""" raise AttributeError( # pragma: no cover f"{type(self).__name__!r} object has no attribute {__name!r}" diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index a62d976e..b4cf759f 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -1,3 +1,7 @@ +# WARNING: do not modify this code, it is generated by _signal.py.jinja2 + +# WARNING: do not modify this code, it is generated by _signal.py.jinja2 + """The main Signal class and SignalInstance class. A note on the "reemission" parameter in Signal and SignalInstances. This controls the @@ -147,8 +151,8 @@ def ensure_at_least_20(val: int): Iterable, Iterator, Literal, + NewType, NoReturn, - Type, TypeVar, TypeVarTuple, Union, @@ -182,10 +186,18 @@ def ensure_at_least_20(val: int): ReducerFunc = Union[ReducerOneArg, ReducerTwoArgs] -T1 = TypeVar("T1") -T2 = TypeVar("T2") -T3 = TypeVar("T3") -T4 = TypeVar("T4") +# ------ BEGIN Generated TypeVars + +_T1 = TypeVar("_T1") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") +_T4 = TypeVar("_T4") +_T5 = TypeVar("_T5") + +# ------ END Generated TypeVars + +GroupSignalInstance = NewType("GroupSignalInstance", object) +RetT = TypeVar("RetT") Ts = TypeVarTuple("Ts") __all__ = ["Signal", "SignalInstance", "_compiled"] @@ -312,22 +324,12 @@ def __init__( self._reemission = reemission self._signal_instance_class: type[SignalInstance] = SignalInstance self._signal_instance_cache: dict[int, SignalInstance] = {} - - if types and isinstance(types[0], Signature): - self._signature = types[0] - if len(types) > 1: - warnings.warn( - "Only a single argument is accepted when directly providing a" - f" `Signature`. These args were ignored: {types[1:]}", # type: ignore - stacklevel=2, - ) - else: - self._signature = _build_signature(*cast("tuple[Type[Any], ...]", types)) + self._types = types @property def signature(self) -> Signature: """[Signature][inspect.Signature] supported by this Signal.""" - return self._signature + return _build_signature(self._types) def __set_name__(self, owner: type[Any], name: str) -> None: """Set name of signal when declared as a class attribute on `owner`.""" @@ -402,7 +404,7 @@ def _create_signal_instance( self, instance: Any, name: str | None = None ) -> SignalInstance[*Ts]: return self._signal_instance_class( - self.signature, + self._types, instance=instance, name=name or self._name, check_nargs_on_connect=self._check_nargs_on_connect, @@ -513,41 +515,17 @@ class Emitter: _is_paused: bool = False _debug_hook: ClassVar[Callable[[EmissionInfo], None] | None] = None - @overload - def __init__( - self: SignalInstance[()], - *, - instance: Any = None, - name: str | None = None, - check_nargs_on_connect: bool = True, - check_types_on_connect: bool = False, - reemission: ReemissionVal = DEFAULT_REEMISSION, - ) -> None: ... - @overload def __init__( self, - signature: tuple[*Ts] | Signature = _empty_signature, - *, - instance: Any = None, - name: str | None = None, - check_nargs_on_connect: bool = True, - check_types_on_connect: bool = False, - reemission: ReemissionVal = DEFAULT_REEMISSION, - ) -> None: ... - def __init__( - self, - signature: tuple[*Ts] | Signature = _empty_signature, - *, + types: tuple[*Ts] = (), # type: ignore instance: Any = None, name: str | None = None, check_nargs_on_connect: bool = True, check_types_on_connect: bool = False, reemission: ReemissionVal = DEFAULT_REEMISSION, ) -> None: - if isinstance(signature, (list, tuple)): - signature = _build_signature(*signature) - elif not isinstance(signature, Signature): # pragma: no cover - raise TypeError( + if not isinstance(types, (list, tuple, Signature)): + raise TypeError( # pragma: no cover "`signature` must be either a sequence of types, or an " "instance of `inspect.Signature`" ) @@ -556,7 +534,7 @@ def __init__( self._name = name self._instance: Callable = self._instance_ref(instance) self._args_queue: list[tuple] = [] # filled when paused - self._signature = signature + self._types = types self._check_nargs_on_connect = check_nargs_on_connect self._check_types_on_connect = check_types_on_connect self._slots: list[WeakCallback] = [] @@ -591,7 +569,7 @@ def _instance_ref(instance: Any) -> Callable[[], Any]: @property def signature(self) -> Signature: """Signature supported by this `SignalInstance`.""" - return self._signature + return _build_signature(*self._types) @property def instance(self) -> Any: @@ -609,10 +587,11 @@ def __repr__(self) -> str: instance = f" on {self.instance!r}" if self.instance is not None else "" return f"<{type(self).__name__}{name}{instance}>" + # ---- BEGIN autgenerated connect overloads @overload def connect( self: SignalInstance[()], - slot: Callable[[], Any], + slot: Callable[[], RetT], *, thread: threading.Thread | Literal["main", "current"] | None = ..., check_nargs: bool | None = ..., @@ -621,11 +600,11 @@ def connect( max_args: int | None = None, on_ref_error: RefErrorChoice = ..., priority: int = ..., - ) -> Callable[[], Any]: ... + ) -> Callable[[], RetT]: ... @overload def connect( - self: SignalInstance[type[T1]], - slot: Callable[[], Any] | Callable[[T1], Any], + self: SignalInstance[type[_T1]], + slot: Callable[[], RetT], *, thread: threading.Thread | Literal["main", "current"] | None = ..., check_nargs: bool | None = ..., @@ -634,11 +613,11 @@ def connect( max_args: int | None = None, on_ref_error: RefErrorChoice = ..., priority: int = ..., - ) -> Callable[[], Any]: ... + ) -> Callable[[], RetT]: ... @overload def connect( - self: SignalInstance[type[T1], type[T2]], - slot: Callable[[], Any] | Callable[[T1], Any] | Callable[[T1, T2], Any], + self: SignalInstance[type[_T1]], + slot: Callable[[_T1], RetT], *, thread: threading.Thread | Literal["main", "current"] | None = ..., check_nargs: bool | None = ..., @@ -647,14 +626,11 @@ def connect( max_args: int | None = None, on_ref_error: RefErrorChoice = ..., priority: int = ..., - ) -> Callable[[], Any]: ... + ) -> Callable[[_T1], RetT]: ... @overload def connect( - self: SignalInstance[type[T1], type[T2], type[T3]], - slot: Callable[[], Any] - | Callable[[T1], Any] - | Callable[[T1, T2], Any] - | Callable[[T1, T2, T3], Any], + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[], RetT], *, thread: threading.Thread | Literal["main", "current"] | None = ..., check_nargs: bool | None = ..., @@ -663,12 +639,234 @@ def connect( max_args: int | None = None, on_ref_error: RefErrorChoice = ..., priority: int = ..., - ) -> Callable[[], Any]: ... - # fallback overload for unparameterized version - # takes any function and returns it + ) -> Callable[[], RetT]: ... @overload def connect( - self, + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4, _T5], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[_T1, _T2, _T3, _T4, _T5], RetT]: ... + + # typing these are hard... we fall back slot: F -> F + # ---- END autgenerated connect overloads + @overload + def connect( + self: SignalInstance[GroupSignalInstance], slot: F, *, thread: threading.Thread | Literal["main", "current"] | None = ..., @@ -679,7 +877,8 @@ def connect( on_ref_error: RefErrorChoice = ..., priority: int = ..., ) -> F: ... - @overload # decorator version with no parameters + # decorator version with no parameters + @overload def connect( self, *, @@ -691,6 +890,7 @@ def connect( on_ref_error: RefErrorChoice = ..., priority: int = ..., ) -> Callable[[F], F]: ... + # implementation def connect( self, slot: Callable | None = None, @@ -1468,7 +1668,7 @@ def paused( def __getstate__(self) -> dict: """Return dict of current state, for pickle.""" attrs = ( - "_signature", + "_types", "_name", "_is_blocked", "_is_paused", diff --git a/src/psygnal/_signal.py.jinja2 b/src/psygnal/_signal.py.jinja2 new file mode 100644 index 00000000..0b812023 --- /dev/null +++ b/src/psygnal/_signal.py.jinja2 @@ -0,0 +1,1711 @@ +# WARNING: do not modify this code, it is generated by _signal.py.jinja2 + +"""The main Signal class and SignalInstance class. + +A note on the "reemission" parameter in Signal and SignalInstances. This controls the +behavior of the signal when a callback emits the signal. + +Since it can be a little confusing, take the following example of a Signal that emits an +integer. We'll connect three callbacks to it, two of which re-emit the same signal with +a different value: + +```python +from psygnal import SignalInstance + +# a signal that emits an integer +sig = SignalInstance((int,), reemission="...") + + +def cb1(value: int) -> None: + print(f"calling cb1 with: {value}") + if value == 1: + # cb1 ALSO triggers an emission of the value 2 + sig.emit(2) + + +def cb2(value: int) -> None: + print(f"calling cb2 with: {value}") + if value == 2: + # cb2 ALSO triggers an emission of the value 3 + sig.emit(3) + + +def cb3(value: int) -> None: + print(f"calling cb3 with: {value}") + + +sig.connect(cb1) +sig.connect(cb2) +sig.connect(cb3) +sig.emit(1) +``` + +with `reemission="queued"` above: you see a breadth-first pattern: +ALL callbacks are called with the first emitted value, before ANY of them are called +with the second emitted value (emitted by the first connected callback cb1) + +``` +calling cb1 with: 1 +calling cb2 with: 1 +calling cb3 with: 1 +calling cb1 with: 2 +calling cb2 with: 2 +calling cb3 with: 2 +calling cb1 with: 3 +calling cb2 with: 3 +calling cb3 with: 3 +``` + +with `reemission='immediate'` signals emitted by callbacks are immediately processed by +all callbacks in a deeper level, before returning back to the original loop level to +call the remaining callbacks with the original value. + +``` +calling cb1 with: 1 +calling cb1 with: 2 +calling cb2 with: 2 +calling cb1 with: 3 +calling cb2 with: 3 +calling cb3 with: 3 +calling cb3 with: 2 +calling cb2 with: 1 +calling cb3 with: 1 +``` + +with `reemission='latest'`, just as with 'immediate', signals emitted by callbacks are +immediately processed by all callbacks in a deeper level. But in this case, the +remaining callbacks in the current level are never called with the original value. + +``` +calling cb1 with: 1 +calling cb1 with: 2 +calling cb2 with: 2 +calling cb1 with: 3 +calling cb2 with: 3 +calling cb3 with: 3 +# cb2 is never called with 1 +# cb3 is never called with 1 or 2 +``` + +The real-world scenario in which this usually arises is an EventedModel or dataclass. +Evented models emit signals on `setattr`: + + +```python +class MyModel(EventedModel): + x: int = 1 + + +m = MyModel(x=1) +print("starting value", m.x) + + +@m.events.x.connect +def ensure_at_least_20(val: int): + print("trying to set to", val) + m.x = max(val, 20) + + +m.x = 5 +print("ending value", m.x) +``` + +``` +starting value 1 +trying to set to 5 +trying to set to 20 +ending value 20 +``` + +With EventedModel.__setattr__, you can easily end up with some complicated recursive +behavior if you connect an on-change callback that also sets the value of the model. In +this case `reemission='latest'` is probably the most appropriate, as it will prevent +the callback from being called with the original (now-stale) value. But one can +conceive of other scenarios where `reemission='immediate'` or `reemission='queued'` +might be more appropriate. Qt's default behavior, for example, is similar to +`immediate`, but can also be configured to be like `queued` by changing the +connection type (in that case, depending on threading). +""" + +from __future__ import annotations + +import inspect +import sys +import threading +import warnings +import weakref +from collections import deque +from contextlib import contextmanager, suppress +from functools import lru_cache, partial, reduce +from inspect import Parameter, Signature, isclass +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + ContextManager, + Final, + Generic, + Iterable, + Iterator, + Literal, + NewType, + NoReturn, + TypeVar, + TypeVarTuple, + Union, + cast, + get_args, + get_origin, + get_type_hints, + overload, +) + +from ._exceptions import EmitLoopError +from ._mypyc import mypyc_attr +from ._queue import QueuedCallback +from ._weak_callback import ( + StrongFunction, + WeakCallback, + WeakSetattr, + WeakSetitem, + weak_callback, +) + +if TYPE_CHECKING: + from ._group import EmissionInfo + from ._weak_callback import RefErrorChoice + + # single function that does all the work of reducing an iterable of args + # to a single args + ReducerOneArg = Callable[[Iterable[tuple]], tuple] + # function that takes two args tuples. it will be passed to itertools.reduce + ReducerTwoArgs = Callable[[tuple, tuple], tuple] + ReducerFunc = Union[ReducerOneArg, ReducerTwoArgs] + + +# ------ BEGIN Generated TypeVars + +{% for i in range(number_of_types) -%} +_T{{ i+1 }} = TypeVar("_T{{ i+1 }}") +{% endfor %} +# ------ END Generated TypeVars + +GroupSignalInstance = NewType("GroupSignalInstance", object) +RetT = TypeVar("RetT") +Ts = TypeVarTuple("Ts") + +__all__ = ["Signal", "SignalInstance", "_compiled"] + +_NULL = object() +F = TypeVar("F", bound=Callable) +RECURSION_LIMIT = sys.getrecursionlimit() + +ReemissionVal = Literal["immediate", "queued", "latest-only"] +VALID_REEMISSION = set(ReemissionVal.__args__) # type: ignore +DEFAULT_REEMISSION: ReemissionVal = "immediate" + + +# using basic class instead of enum for easier mypyc compatibility +# this isn't exposed publicly anyway. +class ReemissionMode: + """Enumeration of reemission strategies.""" + + IMMEDIATE: Final = "immediate" + QUEUED: Final = "queued" + LATEST: Final = "latest-only" + + @staticmethod + def validate(value: str) -> str: + value = str(value).lower() + if value not in ReemissionMode._members(): + raise ValueError( + f"Invalid reemission value. Must be one of " + f"{', '.join(ReemissionMode._members())}. Not {value!r}" + ) + return value + + @staticmethod + def _members() -> set[str]: + return VALID_REEMISSION + + +class Signal(Generic[*Ts]): + """Declares a signal emitter on a class. + + This is class implements the [descriptor + protocol](https://docs.python.org/3/howto/descriptor.html#descriptorhowto) + and is designed to be used as a class attribute, with the supported signature types + provided in the constructor: + + ```python + from psygnal import Signal + + + class MyEmitter: + changed = Signal(int) + + + def receiver(arg: int): + print("new value:", arg) + + + emitter = MyEmitter() + emitter.changed.connect(receiver) + emitter.changed.emit(1) # prints 'new value: 1' + ``` + + !!! note + + in the example above, `MyEmitter.changed` is an instance of `Signal`, + and `emitter.changed` is an instance of `SignalInstance`. See the + documentation on [`SignalInstance`][psygnal.SignalInstance] for details + on how to connect to and/or emit a signal on an instance of an object + that has a `Signal`. + + + Parameters + ---------- + *types : Type[Any] | Signature + A sequence of individual types, or a *single* [`inspect.Signature`][] object. + description : str + Optional descriptive text for the signal. (not used internally). + name : str | None + Optional name of the signal. If it is not specified then the name of the + class attribute that is bound to the signal will be used. default None + check_nargs_on_connect : bool + Whether to check the number of positional args against `signature` when + connecting a new callback. This can also be provided at connection time using + `.connect(..., check_nargs=True)`. By default, `True`. + check_types_on_connect : bool + Whether to check the callback parameter types against `signature` when + connecting a new callback. This can also be provided at connection time using + `.connect(..., check_types=True)`. By default, `False`. + reemission : Literal["immediate", "queued", "latest-only"] | None + Determines the order and manner in which connected callbacks are invoked when a + callback re-emits a signal. Default is `"immediate"`. + + * `"immediate"`: Signals emitted by callbacks are immediately processed in a + deeper emission loop, before returning to process signals emitted at the + current level (after all callbacks in the deeper level have been called). + + * `"queued"`: Signals emitted by callbacks are enqueued for emission after the + current level of emission is complete. This ensures *all* connected + callbacks are called with the first emitted value, before *any* of them are + called with values emitted while calling callbacks. + + * `"latest-only"`: Signals emitted by callbacks are immediately processed in a + deeper emission loop, and remaining callbacks in the current level are never + called with the original value. + """ + + # _signature: Signature # callback signature for this signal + + _current_emitter: ClassVar[SignalInstance | None] = None + + def __init__( + self, + *types: *Ts, + description: str = "", + name: str | None = None, + check_nargs_on_connect: bool = True, + check_types_on_connect: bool = False, + reemission: ReemissionVal = DEFAULT_REEMISSION, + ) -> None: + self._name = name + self.description = description + self._check_nargs_on_connect = check_nargs_on_connect + self._check_types_on_connect = check_types_on_connect + self._reemission = reemission + self._signal_instance_class: type[SignalInstance] = SignalInstance + self._signal_instance_cache: dict[int, SignalInstance] = {} + self._types = types + + @property + def signature(self) -> Signature: + """[Signature][inspect.Signature] supported by this Signal.""" + return _build_signature(self._types) + + def __set_name__(self, owner: type[Any], name: str) -> None: + """Set name of signal when declared as a class attribute on `owner`.""" + if self._name is None: + self._name = name + + @overload + def __get__( + self, instance: None, owner: type[Any] | None = None + ) -> Signal[*Ts]: ... + + @overload + def __get__( + self, instance: Any, owner: type[Any] | None = None + ) -> SignalInstance[*Ts]: ... + + def __get__( + self, instance: Any, owner: type[Any] | None = None + ) -> Signal[*Ts] | SignalInstance[*Ts]: + """Get signal instance. + + This is called when accessing a Signal instance. If accessed as an + attribute on the class `owner`, instance, will be `None`. Otherwise, + if `instance` is not None, we're being accessed on an instance of `owner`. + + class Emitter: + signal = Signal() + + e = Emitter() + + E.signal # instance will be None, owner will be Emitter + e.signal # instance will be e, owner will be Emitter + + Returns + ------- + Signal or SignalInstance + Depending on how this attribute is accessed. + """ + if instance is None: + return self + if id(instance) in self._signal_instance_cache: + return self._signal_instance_cache[id(instance)] + signal_instance = self._create_signal_instance(instance) + + # cache this signal instance so that future access returns the same instance. + try: + # first, try to assign it to instance.name ... this essentially breaks the + # descriptor, (i.e. __get__ will never again be called for this instance) + # (note, this is the same mechanism used in the `cached_property` decorator) + setattr(instance, cast("str", self._name), signal_instance) + except AttributeError: + # if that fails, which may happen in slotted classes, then we fall back to + # our internal cache + self._cache_signal_instance(instance, signal_instance) + + return signal_instance + + def _cache_signal_instance( + self, instance: Any, signal_instance: SignalInstance + ) -> None: + """Cache a signal instance on the instance.""" + # fallback signal instance cache as last resort. We use the object id + # instead a WeakKeyDictionary because we can't guarantee that the instance + # is hashable or weak-referenceable. and we use a finalize to remove the + # cache when the instance is destroyed (if the object is weak-referenceable). + obj_id = id(instance) + self._signal_instance_cache[obj_id] = signal_instance + with suppress(TypeError): + weakref.finalize(instance, self._signal_instance_cache.pop, obj_id, None) + + def _create_signal_instance( + self, instance: Any, name: str | None = None + ) -> SignalInstance[*Ts]: + return self._signal_instance_class( + self._types, + instance=instance, + name=name or self._name, + check_nargs_on_connect=self._check_nargs_on_connect, + check_types_on_connect=self._check_types_on_connect, + reemission=self._reemission, + ) + + @classmethod + @contextmanager + def _emitting(cls, emitter: SignalInstance) -> Iterator[None]: + """Context that sets the sender on a receiver object while emitting a signal.""" + previous, cls._current_emitter = cls._current_emitter, emitter + try: + yield + finally: + cls._current_emitter = previous + + @classmethod + def current_emitter(cls) -> SignalInstance | None: + """Return currently emitting `SignalInstance`, if any. + + This will typically be used in a callback. + + Examples + -------- + ```python + from psygnal import Signal + + + def my_callback(): + source = Signal.current_emitter() + ``` + """ + return cls._current_emitter + + @classmethod + def sender(cls) -> Any: + """Return currently emitting object, if any. + + This will typically be used in a callback. + """ + return getattr(cls._current_emitter, "instance", None) + + +_empty_signature = Signature() + + +@mypyc_attr(allow_interpreted_subclasses=True) +class SignalInstance(Generic[*Ts]): + """A signal instance (optionally) bound to an object. + + In most cases, users will not create a `SignalInstance` directly -- instead + creating a [Signal][psygnal.Signal] class attribute. This object will be + instantiated by the `Signal.__get__` method (i.e. the descriptor protocol), + when a `Signal` instance is accessed from an *instance* of a class with `Signal` + attribute. + + However, it is the `SignalInstance` that you will most often be interacting + with when you access the name of a `Signal` on an instance -- so understanding + the `SignalInstance` API is key to using psygnal. + + ```python + class Emitter: + signal = Signal() + + + e = Emitter() + + # when accessed on an *instance* of Emitter, + # the signal attribute will be a SignalInstance + e.signal + + # This is what you will use to connect your callbacks + e.signal.connect(some_callback) + ``` + + Parameters + ---------- + signature : Signature | None + The signature that this signal accepts and will emit, by default `Signature()`. + instance : Any + An object to which this signal is bound. Normally this will be provided by the + `Signal.__get__` method (see above). However, an unbound `SignalInstance` + may also be created directly. by default `None`. + name : str | None + An optional name for this signal. Normally this will be provided by the + `Signal.__get__` method. by default `None` + check_nargs_on_connect : bool + Whether to check the number of positional args against `signature` when + connecting a new callback. This can also be provided at connection time using + `.connect(..., check_nargs=True)`. By default, `True`. + check_types_on_connect : bool + Whether to check the callback parameter types against `signature` when + connecting a new callback. This can also be provided at connection time using + `.connect(..., check_types=True)`. By default, `False`. + reemission : Literal["immediate", "queued", "latest-only"] | None + See docstring for [`Signal`][psygnal.Signal] for details. + By default, `"immediate"`. + + Raises + ------ + TypeError + If `signature` is neither an instance of `inspect.Signature`, or a `tuple` + of types. + """ + + _is_blocked: bool = False + _is_paused: bool = False + _debug_hook: ClassVar[Callable[[EmissionInfo], None] | None] = None + + def __init__( + self, + types: tuple[*Ts] = (), # type: ignore + instance: Any = None, + name: str | None = None, + check_nargs_on_connect: bool = True, + check_types_on_connect: bool = False, + reemission: ReemissionVal = DEFAULT_REEMISSION, + ) -> None: + if not isinstance(types, (list, tuple, Signature)): + raise TypeError( # pragma: no cover + "`signature` must be either a sequence of types, or an " + "instance of `inspect.Signature`" + ) + + self._reemission = ReemissionMode.validate(reemission) + self._name = name + self._instance: Callable = self._instance_ref(instance) + self._args_queue: list[tuple] = [] # filled when paused + self._types = types + self._check_nargs_on_connect = check_nargs_on_connect + self._check_types_on_connect = check_types_on_connect + self._slots: list[WeakCallback] = [] + self._is_blocked: bool = False + self._is_paused: bool = False + self._lock = threading.RLock() + self._emit_queue: deque[tuple] = deque() + self._recursion_depth: int = 0 + self._max_recursion_depth: int = 0 + self._run_emit_loop_inner: Callable[[], None] + if self._reemission == ReemissionMode.QUEUED: + self._run_emit_loop_inner = self._run_emit_loop_queued + elif self._reemission == ReemissionMode.LATEST: + self._run_emit_loop_inner = self._run_emit_loop_latest_only + else: + self._run_emit_loop_inner = self._run_emit_loop_immediate + + # whether any slots in self._slots have a priority other than 0 + self._priority_in_use = False + + @staticmethod + def _instance_ref(instance: Any) -> Callable[[], Any]: + if instance is None: + return lambda: None + + try: + return weakref.ref(instance) + except TypeError: + # fall back to strong reference if instance is not weak-referenceable + return lambda: instance + + @property + def signature(self) -> Signature: + """Signature supported by this `SignalInstance`.""" + return _build_signature(*self._types) + + @property + def instance(self) -> Any: + """Object that emits this `SignalInstance`.""" + return self._instance() + + @property + def name(self) -> str: + """Name of this `SignalInstance`.""" + return self._name or "" + + def __repr__(self) -> str: + """Return repr.""" + name = f" {self._name!r}" if self._name else "" + instance = f" on {self.instance!r}" if self.instance is not None else "" + return f"<{type(self).__name__}{name}{instance}>" + + # ---- BEGIN autgenerated connect overloads + {%- for sig in connect_overloads %} + @overload + def connect( + {% for arg in sig.arguments -%} + {{ arg.name }}: {{ arg.hint }}, + {% endfor %} + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> {{ sig.return_hint }}: ... + {%- endfor %} + # ---- END autgenerated connect overloads + + # typing these are hard... we fall back slot: F -> F + @overload + def connect( + self: SignalInstance[GroupSignalInstance], + slot: F, + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> F: ... + # decorator version with no parameters + @overload + def connect( + self, + *, + thread: threading.Thread | Literal["main", "current"] | None = ..., + check_nargs: bool | None = ..., + check_types: bool | None = ..., + unique: bool | str = ..., + max_args: int | None = None, + on_ref_error: RefErrorChoice = ..., + priority: int = ..., + ) -> Callable[[F], F]: ... + # implementation + def connect( + self, + slot: Callable | None = None, + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable: + """Connect a callback (`slot`) to this signal. + + `slot` is compatible if: + + * it requires no more than the number of positional arguments emitted by this + `SignalInstance`. (It *may* require less) + * it has no *required* keyword arguments (keyword only arguments that have + no default). + * if `check_types` is `True`, the parameter types in the callback signature must + match the signature of this `SignalInstance`. + + This method may be used as a decorator. + + ```python + @signal.connect + def my_function(): ... + ``` + + !!!important + If a signal is connected with `thread != None`, then it is up to the user + to ensure that `psygnal.emit_queued` is called, or that one of the backend + convenience functions is used (e.g. `psygnal.qt.start_emitting_from_queue`). + Otherwise, callbacks that are connected to signals that are emitted from + another thread will never be called. + + Parameters + ---------- + slot : Callable + A callable to connect to this signal. If the callable accepts less + arguments than the signature of this slot, then they will be discarded when + calling the slot. + check_nargs : Optional[bool] + If `True` and the provided `slot` requires more positional arguments than + the signature of this Signal, raise `TypeError`. by default `True`. + thread: Thread | Literal["main", "current"] | None + If `None` (the default), this slot will be invoked immediately when a signal + is emitted, from whatever thread emitted the signal. If a thread object is + provided, then the callback will only be immediately invoked if the signal + is emitted from that thread. Otherwise, the callback will be added to a + queue. **Note!**, when using the `thread` parameter, the user is responsible + for calling `psygnal.emit_queued()` in the corresponding thread, otherwise + the slot will never be invoked. (See note above). (The strings `"main"` and + `"current"` are also accepted, and will be interpreted as the + `threading.main_thread()` and `threading.current_thread()`, respectively). + check_types : Optional[bool] + If `True`, An additional check will be performed to make sure that types + declared in the slot signature are compatible with the signature + declared by this signal, by default `False`. + unique : Union[bool, str, None] + If `True`, returns without connecting if the slot has already been + connected. If the literal string "raise" is passed to `unique`, then a + `ValueError` will be raised if the slot is already connected. + By default `False`. + max_args : Optional[int] + If provided, `slot` will be called with no more more than `max_args` when + this SignalInstance is emitted. (regardless of how many arguments are + emitted). + on_ref_error : {'raise', 'warn', 'ignore'}, optional + What to do if a weak reference cannot be created. If 'raise', a + ReferenceError will be raised. If 'warn' (default), a warning will be + issued and a strong-reference will be used. If 'ignore' a strong-reference + will be used (silently). + priority : int + The priority of the callback. This is used to determine the order in which + callbacks are called when multiple are connected to the same signal. + Higher priority callbacks are called first. Negative values are allowed. + The default is 0. + + Raises + ------ + TypeError + If a non-callable object is provided. + ValueError + If the provided slot fails validation, either due to mismatched positional + argument requirements, or failed type checking. + ValueError + If `unique` is `True` and `slot` has already been connected. + """ + if check_nargs is None: + check_nargs = self._check_nargs_on_connect + if check_types is None: + check_types = self._check_types_on_connect + + def _wrapper( + slot: F, + max_args: int | None = max_args, + _on_ref_err: RefErrorChoice = on_ref_error, + ) -> F: + if not callable(slot): + raise TypeError(f"Cannot connect to non-callable object: {slot}") + + with self._lock: + if unique and slot in self: + if unique == "raise": + raise ValueError( + "Slot already connect. Use `connect(..., unique=False)` " + "to allow duplicate connections" + ) + return slot + + slot_sig: Signature | None = None + if check_nargs and (max_args is None): + slot_sig, max_args, isqt = self._check_nargs(slot, self.signature) + if isqt: + _on_ref_err = "ignore" + if check_types: + slot_sig = slot_sig or signature(slot) + if not _parameter_types_match(slot, self.signature, slot_sig): + extra = f"- Slot types {slot_sig} do not match types in signal." + self._raise_connection_error(slot, extra) + + cb = weak_callback( + slot, + max_args=max_args, + finalize=self._try_discard, + on_ref_error=_on_ref_err, + priority=priority, + ) + if thread is not None: + cb = QueuedCallback(cb, thread=thread) + self._append_slot(cb) + return slot + + return _wrapper if slot is None else _wrapper(slot) # type: ignore + + def _append_slot(self, slot: WeakCallback) -> None: + """Append a slot to the list of slots. + + Implementing this as a method allows us to override/extend it in subclasses. + """ + # if no previously connected slots have a priority, and this slot also + # has no priority, we can just (quickly) append it to the end of the list. + if not self._priority_in_use: + if not slot.priority: + self._slots.append(slot) + return + # remember that we have a priority in use, so we skip this check + self._priority_in_use = True + + # otherwise we need to (slowly) iterate over self._slots to + # insert the slot in the correct position based on priority. + # High priority slots are placed at the front of the list + # low/negative priority slots are at the end of the list + for i, s in enumerate(self._slots): + if s.priority < slot.priority: + self._slots.insert(i, slot) + return + self._slots.append(slot) + + def _remove_slot(self, slot: Literal["all"] | int | WeakCallback) -> None: + """Remove a slot from the list of slots.""" + # implementing this as a method allows us to override/extend it in subclasses + if slot == "all": + self._slots.clear() + elif isinstance(slot, int): + self._slots.pop(slot) + else: + self._slots.remove(cast("WeakCallback", slot)) + + def _try_discard(self, callback: WeakCallback, missing_ok: bool = True) -> None: + """Try to discard a callback from the list of slots. + + Parameters + ---------- + callback : WeakCallback + A callback to discard. + missing_ok : bool, optional + If `True`, do not raise an error if the callback is not found in the list. + """ + try: + self._remove_slot(callback) + except ValueError: + if not missing_ok: + raise + + def connect_setattr( + self, + obj: object, + attr: str, + maxargs: int | None | object = _NULL, + *, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> WeakCallback[None]: + """Bind an object attribute to the emitted value of this signal. + + Equivalent to calling `self.connect(functools.partial(setattr, obj, attr))`, + but with additional weakref safety (i.e. a strong reference to `obj` will not + be retained). The return object can be used to + [`disconnect()`][psygnal.SignalInstance.disconnect], (or you can use + [`disconnect_setattr()`][psygnal.SignalInstance.disconnect_setattr]). + + Parameters + ---------- + obj : object + An object. + attr : str + The name of an attribute on `obj` that should be set to the value of this + signal when emitted. + maxargs : Optional[int] + max number of positional args to accept + on_ref_error: {'raise', 'warn', 'ignore'}, optional + What to do if a weak reference cannot be created. If 'raise', a + ReferenceError will be raised. If 'warn' (default), a warning will be + issued and a strong-reference will be used. If 'ignore' a strong-reference + will be used (silently). + priority : int + The priority of the callback. This is used to determine the order in which + callbacks are called when multiple are connected to the same signal. + Higher priority callbacks are called first. Negative values are allowed. + The default is 0. + + Returns + ------- + Tuple + (weakref.ref, name, callable). Reference to the object, name of the + attribute, and setattr closure. Can be used to disconnect the slot. + + Raises + ------ + ValueError + If this is not a single-value signal + AttributeError + If `obj` has no attribute `attr`. + + Examples + -------- + >>> class T: + ... sig = Signal(int) + >>> class SomeObj: + ... x = 1 + >>> t = T() + >>> my_obj = SomeObj() + >>> t.sig.connect_setattr(my_obj, "x") + >>> t.sig.emit(5) + >>> assert my_obj.x == 5 + """ + if maxargs is _NULL: + warnings.warn( + "The default value of maxargs will change from `None` to `1` in " + "version 0.11. To silence this warning, provide an explicit value for " + "maxargs (`None` for current behavior, `1` for future behavior).", + FutureWarning, + stacklevel=2, + ) + maxargs = None + + if not hasattr(obj, attr): + raise AttributeError(f"Object {obj} has no attribute {attr!r}") + + with self._lock: + caller = WeakSetattr( + obj, + attr, + max_args=cast("int | None", maxargs), + finalize=self._try_discard, + on_ref_error=on_ref_error, + priority=priority, + ) + self._append_slot(caller) + return caller + + def disconnect_setattr( + self, obj: object, attr: str, missing_ok: bool = True + ) -> None: + """Disconnect a previously connected attribute setter. + + Parameters + ---------- + obj : object + An object. + attr : str + The name of an attribute on `obj` that was previously used for + `connect_setattr`. + missing_ok : bool + If `False` and the provided `slot` is not connected, raises `ValueError`. + by default `True` + + Raises + ------ + ValueError + If `missing_ok` is `True` and no attribute setter is connected. + """ + with self._lock: + cb = WeakSetattr(obj, attr, on_ref_error="ignore") + self._try_discard(cb, missing_ok) + + def connect_setitem( + self, + obj: object, + key: str, + maxargs: int | None | object = _NULL, + *, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> WeakCallback[None]: + """Bind a container item (such as a dict key) to emitted value of this signal. + + Equivalent to calling `self.connect(functools.partial(obj.__setitem__, attr))`, + but with additional weakref safety (i.e. a strong reference to `obj` will not + be retained). The return object can be used to + [`disconnect()`][psygnal.SignalInstance.disconnect], (or you can use + [`disconnect_setitem()`][psygnal.SignalInstance.disconnect_setitem]). + + Parameters + ---------- + obj : object + An object. + key : str + Name of the key in `obj` that should be set to the value of this + signal when emitted + maxargs : Optional[int] + max number of positional args to accept + on_ref_error: {'raise', 'warn', 'ignore'}, optional + What to do if a weak reference cannot be created. If 'raise', a + ReferenceError will be raised. If 'warn' (default), a warning will be + issued and a strong-reference will be used. If 'ignore' a strong-reference + will be used (silently). + priority : int + The priority of the callback. This is used to determine the order in which + callbacks are called when multiple are connected to the same signal. + Higher priority callbacks are called first. Negative values are allowed. + The default is 0. + + Returns + ------- + Tuple + (weakref.ref, name, callable). Reference to the object, name of the + attribute, and setitem closure. Can be used to disconnect the slot. + + Raises + ------ + ValueError + If this is not a single-value signal + TypeError + If `obj` does not support __setitem__. + + Examples + -------- + >>> class T: + ... sig = Signal(int) + >>> t = T() + >>> my_obj = dict() + >>> t.sig.connect_setitem(my_obj, "x") + >>> t.sig.emit(5) + >>> assert my_obj == {"x": 5} + """ + if maxargs is _NULL: + warnings.warn( + "The default value of maxargs will change from `None` to `1` in" + "version 0.11. To silence this warning, provide an explicit value for " + "maxargs (`None` for current behavior, `1` for future behavior).", + FutureWarning, + stacklevel=2, + ) + maxargs = None + + if not hasattr(obj, "__setitem__"): + raise TypeError(f"Object {obj} does not support __setitem__") + + with self._lock: + caller = WeakSetitem( + obj, + key, + max_args=cast("int | None", maxargs), + finalize=self._try_discard, + on_ref_error=on_ref_error, + priority=priority, + ) + self._append_slot(caller) + + return caller + + def disconnect_setitem( + self, obj: object, key: str, missing_ok: bool = True + ) -> None: + """Disconnect a previously connected item setter. + + Parameters + ---------- + obj : object + An object. + key : str + The name of a key in `obj` that was previously used for + `connect_setitem`. + missing_ok : bool + If `False` and the provided `slot` is not connected, raises `ValueError`. + by default `True` + + Raises + ------ + ValueError + If `missing_ok` is `True` and no item setter is connected. + """ + if not hasattr(obj, "__setitem__"): + raise TypeError(f"Object {obj} does not support __setitem__") + + with self._lock: + caller = WeakSetitem(obj, key, on_ref_error="ignore") + self._try_discard(caller, missing_ok) + + def _check_nargs( + self, slot: Callable, spec: Signature + ) -> tuple[Signature | None, int | None, bool]: + """Make sure slot is compatible with signature. + + Also returns the maximum number of arguments that we can pass to the slot + + Returns + ------- + slot_sig : Signature | None + The signature of the slot, or None if it could not be determined. + maxargs : int | None + The maximum number of arguments that we can pass to the slot. + is_qt : bool + Whether the slot is a Qt slot. + """ + try: + slot_sig = _get_signature_possibly_qt(slot) + except ValueError as e: + warnings.warn( + f"{e}. To silence this warning, connect with " "`check_nargs=False`", + stacklevel=2, + ) + return None, None, False + try: + minargs, maxargs = _acceptable_posarg_range(slot_sig) + except ValueError as e: + if isinstance(slot, partial): + raise ValueError( + f"{e}. (Note: prefer using positional args with " + "functools.partials when possible)." + ) from e + raise + + # if `slot` requires more arguments than we will provide, raise. + if minargs > (n_spec_params := len(spec.parameters)): + extra = ( + f"- Slot requires at least {minargs} positional " + f"arguments, but spec only provides {n_spec_params}" + ) + self._raise_connection_error(slot, extra) + + return None if isinstance(slot_sig, str) else slot_sig, maxargs, True + + def _raise_connection_error(self, slot: Callable, extra: str = "") -> NoReturn: + name = getattr(slot, "__name__", str(slot)) + msg = f"Cannot connect slot {name!r} with signature: {signature(slot)}:\n" + msg += extra + msg += f"\n\nAccepted signature: {self.signature}" + raise ValueError(msg) + + def _slot_index(self, slot: Callable) -> int: + """Get index of `slot` in `self._slots`. Return -1 if not connected.""" + with self._lock: + normed = weak_callback(slot, on_ref_error="ignore") + # NOTE: + # the == method here relies on the __eq__ method of each SlotCaller subclass + return next((i for i, s in enumerate(self._slots) if s == normed), -1) + + def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> None: + """Disconnect slot from signal. + + Parameters + ---------- + slot : callable, optional + The specific slot to disconnect. If `None`, all slots will be disconnected, + by default `None` + missing_ok : Optional[bool] + If `False` and the provided `slot` is not connected, raises `ValueError. + by default `True` + + Raises + ------ + ValueError + If `slot` is not connected and `missing_ok` is False. + """ + with self._lock: + if slot is None: + # NOTE: clearing an empty list is actually a RuntimeError in Qt + self._remove_slot("all") + return + + idx = self._slot_index(slot) + if idx != -1: + self._remove_slot(idx) + elif not missing_ok: + raise ValueError(f"slot is not connected: {slot}") + + def __contains__(self, slot: Callable) -> bool: + """Return `True` if slot is connected.""" + return self._slot_index(slot) >= 0 + + def __len__(self) -> int: + """Return number of connected slots.""" + return len(self._slots) + + def emit( + self, *args: Any, check_nargs: bool = False, check_types: bool = False + ) -> None: + """Emit this signal with arguments `args`. + + !!! note + + `check_args` and `check_types` both add overhead when calling emit. + + Parameters + ---------- + *args : Any + These arguments will be passed when calling each slot (unless the slot + accepts fewer arguments, in which case extra args will be discarded.) + check_nargs : bool + If `False` and the provided arguments cannot be successfully bound to the + signature of this Signal, raise `TypeError`. Incurs some overhead. + by default False. + check_types : bool + If `False` and the provided arguments do not match the types declared by + the signature of this Signal, raise `TypeError`. Incurs some overhead. + by default False. + + Raises + ------ + TypeError + If `check_nargs` and/or `check_types` are `True`, and the corresponding + checks fail. + """ + if self._is_blocked: + return + + if check_nargs: + try: + self.signature.bind(*args) + except TypeError as e: + raise TypeError( + f"Cannot emit args {args} from signal {self!r} with " + f"signature {self.signature}:\n{e}" + ) from e + + if check_types and not _parameter_types_match( + lambda: None, self.signature, _build_signature(*[type(a) for a in args]) + ): + raise TypeError( + f"Types provided to '{self.name}.emit' " + f"{tuple(type(a).__name__ for a in args)} do not match signal " + f"signature: {self.signature}" + ) + + if self._is_paused: + self._args_queue.append(args) + return + + if SignalInstance._debug_hook is not None: + from ._group import EmissionInfo + + SignalInstance._debug_hook(EmissionInfo(self, args)) + + self._run_emit_loop(args) + + def __call__( + self, *args: Any, check_nargs: bool = False, check_types: bool = False + ) -> None: + """Alias for `emit()`. But prefer using `emit()` for clarity.""" + return self.emit(*args, check_nargs=check_nargs, check_types=check_types) + + def _run_emit_loop(self, args: tuple[Any, ...]) -> None: + with self._lock: + self._emit_queue.append(args) + if len(self._emit_queue) > 1: + return + try: + # allow receiver to query sender with Signal.current_emitter() + self._recursion_depth += 1 + self._max_recursion_depth = max( + self._max_recursion_depth, self._recursion_depth + ) + with Signal._emitting(self): + self._run_emit_loop_inner() + except RecursionError as e: + raise RecursionError( + f"RecursionError when " + f"emitting signal {self.name!r} with args {args}" + ) from e + except Exception as cb_err: + if isinstance(cb_err, EmitLoopError): + raise cb_err + loop_err = EmitLoopError( + exc=cb_err, + signal=self, + recursion_depth=self._recursion_depth - 1, + reemission=self._reemission, + emit_queue=self._emit_queue, + ).with_traceback(cb_err.__traceback__) + # this comment will show up in the traceback + raise loop_err from cb_err # emit() call ABOVE || callback error BELOW + finally: + self._recursion_depth -= 1 + # we're back to the root level of the emit loop, reset max_depth + if self._recursion_depth <= 0: + self._max_recursion_depth = 0 + self._recursion_depth = 0 + self._emit_queue.clear() + + def _run_emit_loop_immediate(self) -> None: + args = self._emit_queue.popleft() + for caller in self._slots: + caller.cb(args) + + def _run_emit_loop_latest_only(self) -> None: + self._args = args = self._emit_queue.popleft() + for caller in self._slots: + if self._recursion_depth < self._max_recursion_depth: + # we've already entered a deeper emit loop + # we should drop the remaining slots in this round and return + break + self._caller = caller + caller.cb(args) + + def _run_emit_loop_queued(self) -> None: + i = 0 + while i < len(self._emit_queue): + args = self._emit_queue[i] + for caller in self._slots: + caller.cb(args) + if len(self._emit_queue) > RECURSION_LIMIT: + raise RecursionError + i += 1 + + def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: + """Block this signal from emitting. + + NOTE: the `exclude` argument is only for SignalGroup subclass, but we + have to include it here to make mypyc happy. + """ + self._is_blocked = True + + def unblock(self) -> None: + """Unblock this signal, allowing it to emit.""" + self._is_blocked = False + + def blocked(self) -> ContextManager[None]: + """Context manager to temporarily block this signal. + + Useful if you need to temporarily block all emission of a given signal, + (for example, to avoid a recursive signal loop) + + Examples + -------- + ```python + class MyEmitter: + changed = Signal() + + def make_a_change(self): + self.changed.emit() + + obj = MyEmitter() + + with obj.changed.blocked() + obj.make_a_change() # will NOT emit a changed signal. + ``` + """ + return _SignalBlocker(self) + + def pause(self) -> None: + """Pause all emission and collect *args tuples from emit(). + + args passed to `emit` will be collected and re-emitted when `resume()` is + called. For a context manager version, see `paused()`. + """ + self._is_paused = True + + def resume(self, reducer: ReducerFunc | None = None, initial: Any = _NULL) -> None: + """Resume (unpause) this signal, emitting everything in the queue. + + Parameters + ---------- + reducer : Callable | None + A optional function to reduce the args collected while paused into a single + emitted group of args. If not provided, all emissions will be re-emitted + as they were collected when the signal is resumed. May be: + + - a function that takes two args tuples and returns a single args tuple. + This will be passed to `functools.reduce` and is expected to reduce all + collected/emitted args into a single tuple. + For example, three `emit(1)` events would be reduced and re-emitted as + follows: `self.emit(*functools.reduce(reducer, [(1,), (1,), (1,)]))` + - a function that takes a single argument (an iterable of args tuples) and + returns a tuple (the reduced args). This will be *not* be passed to + `functools.reduce`. If `reducer` is a function that takes a single + argument, `initial` will be ignored. + initial: any, optional + initial value to pass to `functools.reduce` + + Examples + -------- + >>> class T: + ... sig = Signal(int) + >>> t = T() + >>> t.sig.pause() + >>> t.sig.emit(1) + >>> t.sig.emit(2) + >>> t.sig.emit(3) + >>> t.sig.resume(lambda a, b: (a[0].union(set(b)),), (set(),)) + >>> # results in t.sig.emit({1, 2, 3}) + """ + self._is_paused = False + # not sure why this attribute wouldn't be set, but when resuming in + # EventedModel.update, it may be undefined (as seen in tests) + if not getattr(self, "_args_queue", None): + return + if len(self._slots) == 0: + self._args_queue.clear() + return + + if reducer is not None: + if len(inspect.signature(reducer).parameters) == 1: + args = cast("ReducerOneArg", reducer)(self._args_queue) + else: + reducer = cast("ReducerTwoArgs", reducer) + if initial is _NULL: + args = reduce(reducer, self._args_queue) + else: + args = reduce(reducer, self._args_queue, initial) + self._run_emit_loop(args) + else: + for args in self._args_queue: + self._run_emit_loop(args) + self._args_queue.clear() + + def paused( + self, reducer: ReducerFunc | None = None, initial: Any = _NULL + ) -> ContextManager[None]: + """Context manager to temporarily pause this signal. + + Parameters + ---------- + reducer : Callable | None + A optional function to reduce the args collected while paused into a single + emitted group of args. If not provided, all emissions will be re-emitted + as they were collected when the signal is resumed. May be: + + - a function that takes two args tuples and returns a single args tuple. + This will be passed to `functools.reduce` and is expected to reduce all + collected/emitted args into a single tuple. + For example, three `emit(1)` events would be reduced and re-emitted as + follows: `self.emit(*functools.reduce(reducer, [(1,), (1,), (1,)]))` + - a function that takes a single argument (an iterable of args tuples) and + returns a tuple (the reduced args). This will be *not* be passed to + `functools.reduce`. If `reducer` is a function that takes a single + argument, `initial` will be ignored. + initial: any, optional + initial value to pass to `functools.reduce` + + Examples + -------- + >>> with obj.signal.paused(lambda a, b: (a[0].union(set(b)),), (set(),)): + ... t.sig.emit(1) + ... t.sig.emit(2) + ... t.sig.emit(3) + >>> # results in obj.signal.emit({1, 2, 3}) + """ + return _SignalPauser(self, reducer, initial) + + def __getstate__(self) -> dict: + """Return dict of current state, for pickle.""" + attrs = ( + "_types", + "_name", + "_is_blocked", + "_is_paused", + "_args_queue", + "_check_nargs_on_connect", + "_check_types_on_connect", + "_emit_queue", + "_priority_in_use", + "_reemission", + "_max_recursion_depth", + "_recursion_depth", + ) + dd = {slot: getattr(self, slot) for slot in attrs} + dd["_instance"] = self._instance() + dd["_slots"] = [x for x in self._slots if isinstance(x, StrongFunction)] + if len(self._slots) > len(dd["_slots"]): + warnings.warn( + "Pickling a SignalInstance does not copy connected weakly referenced " + "slots.", + stacklevel=2, + ) + + return dd + + def __setstate__(self, state: dict) -> None: + """Restore state from pickle.""" + # don't use __dict__, mypyc doesn't have it + for k, v in state.items(): + if k == "_instance": + self._instance = self._instance_ref(v) + else: + setattr(self, k, v) + self._lock = threading.RLock() + if self._reemission == ReemissionMode.QUEUED: # pragma: no cover + self._run_emit_loop_inner = self._run_emit_loop_queued + elif self._reemission == ReemissionMode.LATEST: # pragma: no cover + self._run_emit_loop_inner = self._run_emit_loop_latest_only + else: + self._run_emit_loop_inner = self._run_emit_loop_immediate + + +class _SignalBlocker: + """Context manager to block and unblock a signal.""" + + def __init__( + self, signal: SignalInstance, exclude: Iterable[str | SignalInstance] = () + ) -> None: + self._signal = signal + self._exclude = exclude + self._was_blocked = signal._is_blocked + + def __enter__(self) -> None: + self._signal.block(exclude=self._exclude) + + def __exit__(self, *args: Any) -> None: + if not self._was_blocked: + self._signal.unblock() + + +class _SignalPauser: + """Context manager to pause and resume a signal.""" + + def __init__( + self, signal: SignalInstance, reducer: ReducerFunc | None, initial: Any + ) -> None: + self._was_paused = signal._is_paused + self._signal = signal + self._reducer = reducer + self._initial = initial + + def __enter__(self) -> None: + self._signal.pause() + + def __exit__(self, *args: Any) -> None: + if not self._was_paused: + self._signal.resume(self._reducer, self._initial) + + +# ############################################################################# +# ############################################################################# + + +def signature(obj: Any) -> inspect.Signature: + try: + return inspect.signature(obj) + except ValueError as e: + with suppress(Exception): + if not inspect.ismethod(obj): + return _stub_sig(obj) + raise e from e + + +_ANYSIG = Signature( + [ + Parameter(name="args", kind=Parameter.VAR_POSITIONAL), + Parameter(name="kwargs", kind=Parameter.VAR_KEYWORD), + ] +) + + +@lru_cache(maxsize=None) +def _stub_sig(obj: Any) -> Signature: + """Called as a backup when inspect.signature fails.""" + import builtins + + # this nonsense is here because it's hard to get the signature of mypyc-compiled + # objects, but we still want to be able to connect a signal instance. + if ( + type(getattr(obj, "__self__", None)) is SignalInstance + and getattr(obj, "__name__", None) == "emit" + ) or type(obj) is SignalInstance: + # we won't reach this in testing because + # Compiled functions don't trigger profiling and tracing hooks + return _ANYSIG # pragma: no cover + + # just a common case + if obj is builtins.print: + params = [ + Parameter(name="value", kind=Parameter.VAR_POSITIONAL), + Parameter(name="sep", kind=Parameter.KEYWORD_ONLY, default=" "), + Parameter(name="end", kind=Parameter.KEYWORD_ONLY, default="\n"), + Parameter(name="file", kind=Parameter.KEYWORD_ONLY, default=None), + Parameter(name="flush", kind=Parameter.KEYWORD_ONLY, default=False), + ] + return Signature(params) + raise ValueError("unknown object") + + +def _build_signature(*types: Any) -> Signature: + params = [ + Parameter(name=f"p{i}", kind=Parameter.POSITIONAL_ONLY, annotation=t) + for i, t in enumerate(types) + ] + return Signature(params) + + +# def f(a, /, b, c=None, *d, f=None, **g): print(locals()) +# +# a: kind=POSITIONAL_ONLY, default=Parameter.empty # 1 required posarg +# b: kind=POSITIONAL_OR_KEYWORD, default=Parameter.empty # 1 requires posarg +# c: kind=POSITIONAL_OR_KEYWORD, default=None # 1 optional posarg +# d: kind=VAR_POSITIONAL, default=Parameter.empty # N optional posargs +# e: kind=KEYWORD_ONLY, default=Parameter.empty # 1 REQUIRED kwarg +# f: kind=KEYWORD_ONLY, default=None # 1 optional kwarg +# g: kind=VAR_KEYWORD, default=Parameter.empty # N optional kwargs + + +def _get_signature_possibly_qt(slot: Callable) -> Signature | str: + # checking qt has to come first, since the signature of the emit method + # of a Qt SignalInstance is just None> + # https://bugreports.qt.io/browse/PYSIDE-1713 + sig = _guess_qtsignal_signature(slot) + return signature(slot) if sig is None else sig + + +def _acceptable_posarg_range( + sig: Signature | str, forbid_required_kwarg: bool = True +) -> tuple[int, int | None]: + """Return tuple of (min, max) accepted positional arguments. + + Parameters + ---------- + sig : Signature + Signature object to evaluate + forbid_required_kwarg : Optional[bool] + Whether to allow required KEYWORD_ONLY parameters. by default True. + + Returns + ------- + arg_range : Tuple[int, int] + minimum, maximum number of acceptable positional arguments + + Raises + ------ + ValueError + If the signature has a required keyword_only parameter and + `forbid_required_kwarg` is `True`. + """ + if isinstance(sig, str): + if "(" not in sig: # pragma: no cover + raise ValueError(f"Unrecognized string signature format: {sig!r}") + inner = sig.split("(", 1)[1].split(")", 1)[0] + minargs = maxargs = inner.count(",") + 1 if inner else 0 + return minargs, maxargs + + required = 0 + optional = 0 + posargs_unlimited = False + _pos_required = {Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD} + for param in sig.parameters.values(): + if param.kind in _pos_required: + if param.default is Parameter.empty: + required += 1 + else: + optional += 1 + elif param.kind is Parameter.VAR_POSITIONAL: + posargs_unlimited = True + elif ( + param.kind is Parameter.KEYWORD_ONLY + and param.default is Parameter.empty + and forbid_required_kwarg + ): + raise ValueError(f"Unsupported KEYWORD_ONLY parameters in signature: {sig}") + return (required, None if posargs_unlimited else required + optional) + + +def _parameter_types_match( + function: Callable, spec: Signature, func_sig: Signature | None = None +) -> bool: + """Return True if types in `function` signature match those in `spec`. + + Parameters + ---------- + function : Callable + A function to validate + spec : Signature + The Signature against which the `function` should be validated. + func_sig : Signature, optional + Signature for `function`, if `None`, signature will be inspected. + by default None + + Returns + ------- + bool + True if the parameter types match. + """ + fsig = func_sig or signature(function) + + func_hints: dict | None = None + for f_param, spec_param in zip(fsig.parameters.values(), spec.parameters.values()): + f_anno = f_param.annotation + if f_anno is fsig.empty: + # if function parameter is not type annotated, allow it. + continue + + if isinstance(f_anno, str): + if func_hints is None: + func_hints = get_type_hints(function) + f_anno = func_hints.get(f_param.name) + + if not _is_subclass(f_anno, spec_param.annotation): + return False + return True + + +def _is_subclass(left: type[Any], right: type) -> bool: + """Variant of issubclass with support for unions.""" + if not isclass(left) and get_origin(left) is Union: + return any(issubclass(i, right) for i in get_args(left)) + return issubclass(left, right) + + +def _guess_qtsignal_signature(obj: Any) -> str | None: + """Return string signature if `obj` is a SignalInstance or Qt emit method. + + This is a bit of a hack, but we found no better way: + https://stackoverflow.com/q/69976089/1631624 + https://bugreports.qt.io/browse/PYSIDE-1713 + """ + # on my machine, this takes ~700ns on PyQt5 and 8.7µs on PySide2 + type_ = type(obj) + if "pyqtBoundSignal" in type_.__name__: + return cast("str", obj.signal) + qualname = getattr(obj, "__qualname__", "") + if qualname == "pyqtBoundSignal.emit": + return cast("str", obj.__self__.signal) + + # note: this IS all actually covered in tests... but only in the Qt tests, + # so it (annoyingly) briefly looks like it fails coverage. + if qualname == "SignalInstance.emit" and type_.__name__.startswith("builtin"): + # we likely have the emit method of a SignalInstance + # call it with ridiculous params to get the err + return _ridiculously_call_emit(obj.__self__.emit) # pragma: no cover + if "SignalInstance" in type_.__name__ and "QtCore" in getattr( + type_, "__module__", "" + ): # pragma: no cover + return _ridiculously_call_emit(obj.emit) + return None + + +_CRAZY_ARGS = (1,) * 255 + + +# note: this IS all actually covered in tests... but only in the Qt tests, +# so it (annoyingly) briefly looks like it fails coverage. +def _ridiculously_call_emit(emitter: Any) -> str | None: # pragma: no cover + """Call SignalInstance emit() to get the signature from err message.""" + try: + emitter(*_CRAZY_ARGS) + except TypeError as e: + if "only accepts" in str(e): + return str(e).split("only accepts")[0].strip() + return None # pragma: no cover + + +_compiled: bool + + +def __getattr__(name: str) -> Any: + if name == "_compiled": + return hasattr(Signal, "__mypyc_attrs__") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tests/test_psygnal.py b/tests/test_psygnal.py index 1e868259..e15673e1 100644 --- a/tests/test_psygnal.py +++ b/tests/test_psygnal.py @@ -3,7 +3,6 @@ import sys from contextlib import suppress from functools import partial, wraps -from inspect import Signature from typing import Literal, Optional from unittest.mock import MagicMock, Mock, call @@ -162,12 +161,12 @@ def test_getattr(): _ = s.not_a_thing -def test_signature_provided(): - s = Signal(Signature()) - assert s.signature == Signature() +# def test_signature_provided(): +# s = Signal(Signature()) +# assert s.signature == Signature() - with pytest.warns(UserWarning): - s = Signal(Signature(), 1) +# with pytest.warns(UserWarning): +# s = Signal(Signature(), 1) def test_emit_checks(): diff --git a/typesafety/test_group.yml b/typesafety/test_group.yml index c0ae15ff..51a588b6 100644 --- a/typesafety/test_group.yml +++ b/typesafety/test_group.yml @@ -12,7 +12,7 @@ t = T() reveal_type(T.e) # N: Revealed type is "psygnal._group_descriptor.SignalGroupDescriptor" reveal_type(t.e) # N: Revealed type is "psygnal._group.SignalGroup" - reveal_type(t.e.x) # N: Revealed type is "psygnal._signal.SignalInstance[Unpack[Ts`-1]]" + reveal_type(t.e.x) # N: Revealed type is "psygnal._signal.SignalInstance[psygnal._signal.GroupSignalInstance]" @t.e['x'].connect def func(x: int) -> None: diff --git a/typesafety/test_signal.yml b/typesafety/test_signal.yml index ca6a8f74..f48dcc91 100644 --- a/typesafety/test_signal.yml +++ b/typesafety/test_signal.yml @@ -18,23 +18,52 @@ s3 = Signal(object) s4 = Signal(Signature()) -- case: signal_connection +- case: signal_connection_checks_types + main: | + from psygnal import Signal + + class Emitter: + changed = Signal(int, bool) + + emitter = Emitter() + + @emitter.changed.connect # ER: Argument 1 to "connect" of "SignalInstance" has incompatible.* + def f(x: int, y: bool, z: str) -> str: + return "" + + @emitter.changed.connect # ER: Argument 1 to "connect" of "SignalInstance" has incompatible.* + def f2(x: int, y: str) -> str: + return "" + + @emitter.changed.connect + def f3(x: int, y: bool) -> str: + return "" + + @emitter.changed.connect + def f4(x: int) -> str: + return "" + + @emitter.changed.connect + def f5() -> str: + return "" + +- case: signal_connection_preserves_function main: | from psygnal import SignalInstance from typing import Any - s = SignalInstance() + s = SignalInstance((int, str)) def a(x: int, y: str) -> Any: ... x = s.connect(a) - reveal_type(x) # N: Revealed type is "def (x: builtins.int, y: builtins.str) -> Any" + reveal_type(x) # N: Revealed type is "def (builtins.int, builtins.str) -> Any" @s.connect - def b(x: str) -> int: return 1 - reveal_type(b) # N: Revealed type is "def (x: builtins.str) -> builtins.int" + def b(x: int) -> int: return 1 + reveal_type(b) # N: Revealed type is "def (builtins.int) -> builtins.int" def c(x: int, y: str) -> Any: ... y = s.connect(c, check_nargs=False) - reveal_type(y) # N: Revealed type is "def (x: builtins.int, y: builtins.str) -> Any" + reveal_type(y) # N: Revealed type is "def (builtins.int, builtins.str) -> Any" @s.connect(check_nargs=False) def d(x: str) -> None: ... From faa76286740f2997e8bf38e52ae936b3aad4cd19 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 31 Mar 2024 22:11:07 -0400 Subject: [PATCH 05/16] remove template comment --- src/psygnal/_signal.py.jinja2 | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/psygnal/_signal.py.jinja2 b/src/psygnal/_signal.py.jinja2 index 0b812023..9fa726f2 100644 --- a/src/psygnal/_signal.py.jinja2 +++ b/src/psygnal/_signal.py.jinja2 @@ -1,5 +1,3 @@ -# WARNING: do not modify this code, it is generated by _signal.py.jinja2 - """The main Signal class and SignalInstance class. A note on the "reemission" parameter in Signal and SignalInstances. This controls the From c17ad1ac5653429fab67e005dfab2f89bead6d3f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 31 Mar 2024 22:15:38 -0400 Subject: [PATCH 06/16] try fix 38 --- src/psygnal/_group.py | 3 --- src/psygnal/_signal.py | 21 ++++++++++----------- src/psygnal/_signal.py.jinja2 | 19 ++++++++++--------- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 61a936e9..1ed69c1b 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -38,13 +38,10 @@ if TYPE_CHECKING: import threading - from typing import TypeVarTuple from psygnal._signal import F, ReducerFunc from psygnal._weak_callback import RefErrorChoice, WeakCallback - Ts = TypeVarTuple("Ts") - __all__ = ["EmissionInfo", "SignalGroup"] diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index b4cf759f..bace3475 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -1,7 +1,5 @@ # WARNING: do not modify this code, it is generated by _signal.py.jinja2 -# WARNING: do not modify this code, it is generated by _signal.py.jinja2 - """The main Signal class and SignalInstance class. A note on the "reemission" parameter in Signal and SignalInstances. This controls the @@ -154,7 +152,6 @@ def ensure_at_least_20(val: int): NewType, NoReturn, TypeVar, - TypeVarTuple, Union, cast, get_args, @@ -163,6 +160,8 @@ def ensure_at_least_20(val: int): overload, ) +from typing_extensions import TypeVarTuple, Unpack + from ._exceptions import EmitLoopError from ._mypyc import mypyc_attr from ._queue import QueuedCallback @@ -235,7 +234,7 @@ def _members() -> set[str]: return VALID_REEMISSION -class Signal(Generic[*Ts]): +class Signal(Generic[Unpack[Ts]]): """Declares a signal emitter on a class. This is class implements the [descriptor @@ -310,7 +309,7 @@ class attribute that is bound to the signal will be used. default None def __init__( self, - *types: *Ts, + *types: Unpack[Ts], description: str = "", name: str | None = None, check_nargs_on_connect: bool = True, @@ -339,16 +338,16 @@ def __set_name__(self, owner: type[Any], name: str) -> None: @overload def __get__( self, instance: None, owner: type[Any] | None = None - ) -> Signal[*Ts]: ... + ) -> Signal[Unpack[Ts]]: ... @overload def __get__( self, instance: Any, owner: type[Any] | None = None - ) -> SignalInstance[*Ts]: ... + ) -> SignalInstance[Unpack[Ts]]: ... def __get__( self, instance: Any, owner: type[Any] | None = None - ) -> Signal[*Ts] | SignalInstance[*Ts]: + ) -> Signal[Unpack[Ts]] | SignalInstance[Unpack[Ts]]: """Get signal instance. This is called when accessing a Signal instance. If accessed as an @@ -402,7 +401,7 @@ def _cache_signal_instance( def _create_signal_instance( self, instance: Any, name: str | None = None - ) -> SignalInstance[*Ts]: + ) -> SignalInstance[Unpack[Ts]]: return self._signal_instance_class( self._types, instance=instance, @@ -453,7 +452,7 @@ def sender(cls) -> Any: @mypyc_attr(allow_interpreted_subclasses=True) -class SignalInstance(Generic[*Ts]): +class SignalInstance(Generic[Unpack[Ts]]): """A signal instance (optionally) bound to an object. In most cases, users will not create a `SignalInstance` directly -- instead @@ -517,7 +516,7 @@ class Emitter: def __init__( self, - types: tuple[*Ts] = (), # type: ignore + types: tuple[Unpack[Ts]] = (), # type: ignore instance: Any = None, name: str | None = None, check_nargs_on_connect: bool = True, diff --git a/src/psygnal/_signal.py.jinja2 b/src/psygnal/_signal.py.jinja2 index 9fa726f2..5f1350f4 100644 --- a/src/psygnal/_signal.py.jinja2 +++ b/src/psygnal/_signal.py.jinja2 @@ -150,7 +150,6 @@ from typing import ( NewType, NoReturn, TypeVar, - TypeVarTuple, Union, cast, get_args, @@ -159,6 +158,8 @@ from typing import ( overload, ) +from typing_extensions import TypeVarTuple, Unpack + from ._exceptions import EmitLoopError from ._mypyc import mypyc_attr from ._queue import QueuedCallback @@ -228,7 +229,7 @@ class ReemissionMode: return VALID_REEMISSION -class Signal(Generic[*Ts]): +class Signal(Generic[Unpack[Ts]]): """Declares a signal emitter on a class. This is class implements the [descriptor @@ -303,7 +304,7 @@ class Signal(Generic[*Ts]): def __init__( self, - *types: *Ts, + *types: Unpack[Ts], description: str = "", name: str | None = None, check_nargs_on_connect: bool = True, @@ -332,16 +333,16 @@ class Signal(Generic[*Ts]): @overload def __get__( self, instance: None, owner: type[Any] | None = None - ) -> Signal[*Ts]: ... + ) -> Signal[Unpack[Ts]]: ... @overload def __get__( self, instance: Any, owner: type[Any] | None = None - ) -> SignalInstance[*Ts]: ... + ) -> SignalInstance[Unpack[Ts]]: ... def __get__( self, instance: Any, owner: type[Any] | None = None - ) -> Signal[*Ts] | SignalInstance[*Ts]: + ) -> Signal[Unpack[Ts]] | SignalInstance[Unpack[Ts]]: """Get signal instance. This is called when accessing a Signal instance. If accessed as an @@ -395,7 +396,7 @@ class Signal(Generic[*Ts]): def _create_signal_instance( self, instance: Any, name: str | None = None - ) -> SignalInstance[*Ts]: + ) -> SignalInstance[Unpack[Ts]]: return self._signal_instance_class( self._types, instance=instance, @@ -446,7 +447,7 @@ _empty_signature = Signature() @mypyc_attr(allow_interpreted_subclasses=True) -class SignalInstance(Generic[*Ts]): +class SignalInstance(Generic[Unpack[Ts]]): """A signal instance (optionally) bound to an object. In most cases, users will not create a `SignalInstance` directly -- instead @@ -510,7 +511,7 @@ class SignalInstance(Generic[*Ts]): def __init__( self, - types: tuple[*Ts] = (), # type: ignore + types: tuple[Unpack[Ts]] = (), # type: ignore instance: Any = None, name: str | None = None, check_nargs_on_connect: bool = True, From ea1de816c407992bb7de4c0b026cd55d713c8f50 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 31 Mar 2024 22:17:20 -0400 Subject: [PATCH 07/16] setup python --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 67fd35c6..c8e22e8e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" - run: | pip install jinja2 CHECK_JINJA=1 python scripts/generate_select.py From 2d14a4734983b7e64e7bfd42136de65a34f5f2e2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 31 Mar 2024 22:17:51 -0400 Subject: [PATCH 08/16] fix manifest --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 3f76ed04..4712dfac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,6 +212,7 @@ omit = ["*/_pyinstaller_util/hook-psygnal.py"] ignore = [ ".ruff_cache/**/*", ".github_changelog_generator", + "scripts/*", ".pre-commit-config.yaml", "tests/**/*", "typesafety/*", From 9377e2c660eacc814310bcda9c962acc91dd4450 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 31 Mar 2024 22:18:18 -0400 Subject: [PATCH 09/16] fix 38 --- src/psygnal/_group.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 1ed69c1b..aa772d48 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -23,6 +23,7 @@ Literal, Mapping, NamedTuple, + Type, overload, ) @@ -59,7 +60,7 @@ class EmissionInfo(NamedTuple): args: tuple[Any, ...] -class SignalRelay(SignalInstance[type[EmissionInfo]]): +class SignalRelay(SignalInstance[Type[EmissionInfo]]): """Special SignalInstance that can be used to connect to all signals in a group. This class will rarely be instantiated by a user (or anything other than a From 4ada3d7064c028a1eec4adb416f2d61d8bb08adf Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 31 Mar 2024 22:37:02 -0400 Subject: [PATCH 10/16] fix signature as arg --- .github/workflows/test.yml | 2 +- src/psygnal/_signal.py | 19 +++++++++++++++++-- src/psygnal/_signal.py.jinja2 | 19 +++++++++++++++++-- tests/test_psygnal.py | 11 ++++++----- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8e22e8e..e27a6a85 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: python-version: "3.x" - run: | pip install jinja2 - CHECK_JINJA=1 python scripts/generate_select.py + CHECK_JINJA=1 python scripts/render_connect_overloads.py test: name: Test diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index bace3475..24dbb7b9 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -316,6 +316,15 @@ def __init__( check_types_on_connect: bool = False, reemission: ReemissionVal = DEFAULT_REEMISSION, ) -> None: + if types and isinstance(types[0], Signature): + if len(types) > 1: + warnings.warn( + "Only a single argument is accepted when directly providing a" + f" `Signature`. These args were ignored: {types[1:]}", # type: ignore + stacklevel=2, + ) + types = tuple(x.annotation for x in types[0].parameters.values()) + self._name = name self.description = description self._check_nargs_on_connect = check_nargs_on_connect @@ -328,7 +337,7 @@ def __init__( @property def signature(self) -> Signature: """[Signature][inspect.Signature] supported by this Signal.""" - return _build_signature(self._types) + return _build_signature(*self._types) def __set_name__(self, owner: type[Any], name: str) -> None: """Set name of signal when declared as a class attribute on `owner`.""" @@ -516,13 +525,19 @@ class Emitter: def __init__( self, - types: tuple[Unpack[Ts]] = (), # type: ignore + types: tuple[Unpack[Ts]] | Signature = (), # type: ignore instance: Any = None, name: str | None = None, check_nargs_on_connect: bool = True, check_types_on_connect: bool = False, reemission: ReemissionVal = DEFAULT_REEMISSION, ) -> None: + if isinstance(types, Signature): + types = tuple( + x.annotation + for x in types.parameters.values() + if x.kind is x.POSITIONAL_OR_KEYWORD + ) if not isinstance(types, (list, tuple, Signature)): raise TypeError( # pragma: no cover "`signature` must be either a sequence of types, or an " diff --git a/src/psygnal/_signal.py.jinja2 b/src/psygnal/_signal.py.jinja2 index 5f1350f4..267f0c88 100644 --- a/src/psygnal/_signal.py.jinja2 +++ b/src/psygnal/_signal.py.jinja2 @@ -311,6 +311,15 @@ class Signal(Generic[Unpack[Ts]]): check_types_on_connect: bool = False, reemission: ReemissionVal = DEFAULT_REEMISSION, ) -> None: + if types and isinstance(types[0], Signature): + if len(types) > 1: + warnings.warn( + "Only a single argument is accepted when directly providing a" + f" `Signature`. These args were ignored: {types[1:]}", # type: ignore + stacklevel=2, + ) + types = tuple(x.annotation for x in types[0].parameters.values()) + self._name = name self.description = description self._check_nargs_on_connect = check_nargs_on_connect @@ -323,7 +332,7 @@ class Signal(Generic[Unpack[Ts]]): @property def signature(self) -> Signature: """[Signature][inspect.Signature] supported by this Signal.""" - return _build_signature(self._types) + return _build_signature(*self._types) def __set_name__(self, owner: type[Any], name: str) -> None: """Set name of signal when declared as a class attribute on `owner`.""" @@ -511,13 +520,19 @@ class SignalInstance(Generic[Unpack[Ts]]): def __init__( self, - types: tuple[Unpack[Ts]] = (), # type: ignore + types: tuple[Unpack[Ts]] | Signature = (), # type: ignore instance: Any = None, name: str | None = None, check_nargs_on_connect: bool = True, check_types_on_connect: bool = False, reemission: ReemissionVal = DEFAULT_REEMISSION, ) -> None: + if isinstance(types, Signature): + types = tuple( + x.annotation + for x in types.parameters.values() + if x.kind is x.POSITIONAL_OR_KEYWORD + ) if not isinstance(types, (list, tuple, Signature)): raise TypeError( # pragma: no cover "`signature` must be either a sequence of types, or an " diff --git a/tests/test_psygnal.py b/tests/test_psygnal.py index e15673e1..1e868259 100644 --- a/tests/test_psygnal.py +++ b/tests/test_psygnal.py @@ -3,6 +3,7 @@ import sys from contextlib import suppress from functools import partial, wraps +from inspect import Signature from typing import Literal, Optional from unittest.mock import MagicMock, Mock, call @@ -161,12 +162,12 @@ def test_getattr(): _ = s.not_a_thing -# def test_signature_provided(): -# s = Signal(Signature()) -# assert s.signature == Signature() +def test_signature_provided(): + s = Signal(Signature()) + assert s.signature == Signature() -# with pytest.warns(UserWarning): -# s = Signal(Signature(), 1) + with pytest.warns(UserWarning): + s = Signal(Signature(), 1) def test_emit_checks(): From ff18b86e5d5d12a4bedb8fef067a8f380de96bb9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 31 Mar 2024 22:38:05 -0400 Subject: [PATCH 11/16] install ruff --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e27a6a85..2d2f8bb9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: with: python-version: "3.x" - run: | - pip install jinja2 + pip install jinja2 ruff CHECK_JINJA=1 python scripts/render_connect_overloads.py test: From 66ddb0c2b5965d00ac869e7348c90cdbbe0bca76 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 1 Apr 2024 18:19:38 -0400 Subject: [PATCH 12/16] Refactor Signature warning message in Signal class --- src/psygnal/_signal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 24dbb7b9..388400ed 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -320,7 +320,7 @@ def __init__( if len(types) > 1: warnings.warn( "Only a single argument is accepted when directly providing a" - f" `Signature`. These args were ignored: {types[1:]}", # type: ignore + " `Signature`. Extra args ignored.", stacklevel=2, ) types = tuple(x.annotation for x in types[0].parameters.values()) From daaefc6cdaac77635c53e074e06b4262581b64c8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 2 Apr 2024 20:12:08 -0400 Subject: [PATCH 13/16] try new strategy --- .github/workflows/test.yml | 4 +- .pre-commit-config.yaml | 2 +- pyproject.toml | 7 +- scripts/build_stub.py | 132 +++ src/psygnal/_group.py | 6 +- src/psygnal/_signal.py | 314 +----- src/psygnal/_signal.py.jinja2 | 1725 --------------------------------- src/psygnal/_signal.pyi | 1388 ++++++++++++++++++++++++++ typesafety/test_group.yml | 2 +- 9 files changed, 1549 insertions(+), 2031 deletions(-) create mode 100644 scripts/build_stub.py delete mode 100644 src/psygnal/_signal.py.jinja2 create mode 100644 src/psygnal/_signal.pyi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d2f8bb9..38d4be1b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,8 +28,8 @@ jobs: with: python-version: "3.x" - run: | - pip install jinja2 ruff - CHECK_JINJA=1 python scripts/render_connect_overloads.py + pip install ruff + CHECK_STUBS=1 python scripts/build_stub.py test: name: Test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6815092a..a580e400 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: rev: v1.8.0 hooks: - id: mypy - exclude: tests|_throttler.pyi + exclude: tests|_throttler.pyi|.*_signal.pyi additional_dependencies: - types-attrs - pydantic diff --git a/pyproject.toml b/pyproject.toml index 4712dfac..29c31738 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,7 +149,7 @@ select = [ "RUF", # ruff-specific rules ] ignore = [ - "D401", # First line should be in imperative mood + "D401", # First line should be in imperative mood ] [tool.ruff.lint.per-file-ignores] @@ -167,8 +167,8 @@ testpaths = ["tests"] filterwarnings = [ "error", "ignore:The distutils package is deprecated:DeprecationWarning:", - "ignore:.*BackendFinder.find_spec()", # pyinstaller import - "ignore:.*not using a cooperative constructor:pytest.PytestDeprecationWarning:" + "ignore:.*BackendFinder.find_spec()", # pyinstaller import + "ignore:.*not using a cooperative constructor:pytest.PytestDeprecationWarning:", ] # https://mypy.readthedocs.io/en/stable/config_file.html @@ -180,6 +180,7 @@ disallow_subclassing_any = false show_error_codes = true pretty = true + [[tool.mypy.overrides]] module = ["numpy.*", "wrapt", "pydantic.*"] ignore_errors = true diff --git a/scripts/build_stub.py b/scripts/build_stub.py new file mode 100644 index 00000000..b6c876c6 --- /dev/null +++ b/scripts/build_stub.py @@ -0,0 +1,132 @@ +"""Build _signal.pyi with def connect @overloads.""" + +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from textwrap import indent + +ROOT = Path(__file__).parent.parent / "src" / "psygnal" +TEMPLATE_PATH = ROOT / "_signal.py.jinja2" +DEST_PATH = TEMPLATE_PATH.with_suffix("") + +# Maximum number of arguments allowed in callbacks +MAX_ARGS = 5 + + +@dataclass +class Arg: + """Single arg.""" + + name: str + hint: str + default: str | None = None + + +@dataclass +class Sig: + """Full signature.""" + + arguments: list[Arg] + return_hint: str + + def render(self) -> str: + """Render the signature as a def connect overload.""" + args = ", ".join(f"{arg.name}: {arg.hint}" for arg in self.arguments) + "," + args += """ + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + """ + return f"\n@overload\ndef connect({args}) -> {self.return_hint}: ..." + + +connect_overloads: list[Sig] = [] +for nself in range(MAX_ARGS + 1): + for ncallback in range(nself + 1): + if nself: + self_types = ", ".join(f"type[_T{i+1}]" for i in range(nself)) + else: + self_types = "()" + arg_types = ", ".join(f"_T{i+1}" for i in range(ncallback)) + slot_type = f"Callable[[{arg_types}], RetT]" + connect_overloads.append( + Sig( + arguments=[ + Arg(name="self", hint=f"SignalInstance[{self_types}]"), + Arg(name="slot", hint=slot_type), + ], + return_hint=slot_type, + ) + ) + +connect_overloads.append( + Sig( + arguments=[ + Arg(name="self", hint="SignalInstance[Unparametrized]"), + Arg(name="slot", hint="F"), + ], + return_hint="F", + ) +) +connect_overloads.append( + Sig( + arguments=[ + Arg(name="self", hint="SignalInstance"), + ], + return_hint="Callable[[F], F]", + ) +) + + +STUB = Path("src/psygnal/_signal.pyi") + + +if __name__ == "__main__": + existing_stub = STUB.read_text() if STUB.exists() else None + + # make a temporary file to write to + with TemporaryDirectory() as tmpdir: + subprocess.run( + [ # noqa + "stubgen", + "--include-private", + # "--include-docstrings", + "src/psygnal/_signal.py", + "-o", + tmpdir, + ] + ) + stub_path = Path(tmpdir) / "psygnal" / "_signal.pyi" + new_stub = "from typing import NewType\n" + stub_path.read_text() + new_stub = new_stub.replace( + "ReemissionVal: Incomplete", + 'ReemissionVal = Literal["immediate", "queued", "latest-only"]', + ) + new_stub = new_stub.replace( + "Unparametrized: Incomplete", + 'Unparametrized = NewType("Unparametrized", object)', + ) + overloads = "\n".join(sig.render() for sig in connect_overloads) + overloads = indent(overloads, " ") + new_stub = re.sub(r"def connect.+\.\.\.", overloads, new_stub) + + stub_path.write_text(new_stub) + subprocess.run(["ruff", "format", tmpdir]) # noqa + subprocess.run(["ruff", "check", tmpdir, "--fix"]) # noqa + new_stub = stub_path.read_text() + + if os.getenv("CHECK_STUBS"): + if existing_stub != new_stub: + raise RuntimeError(f"{STUB} content not up to date.") + sys.exit(0) + + STUB.write_text(new_stub) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index aa772d48..7a07e018 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -29,9 +29,9 @@ from psygnal._signal import ( _NULL, - GroupSignalInstance, Signal, SignalInstance, + Unparametrized, _SignalBlocker, ) @@ -389,7 +389,7 @@ def __len__(self) -> int: """Return the number of signals in the group (not including the relay).""" return len(self._psygnal_instances) - def __getitem__(self, item: str) -> SignalInstance[GroupSignalInstance]: + def __getitem__(self, item: str) -> SignalInstance[Unparametrized]: """Get a signal instance by name.""" return self._psygnal_instances[item] @@ -397,7 +397,7 @@ def __getitem__(self, item: str) -> SignalInstance[GroupSignalInstance]: # where the SignalGroup comes from the SignalGroupDescriptor # (such as in evented dataclasses). In those cases, it's hard to indicate # to mypy that all remaining attributes are SignalInstances. - def __getattr__(self, __name: str) -> SignalInstance[GroupSignalInstance]: + def __getattr__(self, __name: str) -> SignalInstance[Unparametrized]: """Get a signal instance by name.""" raise AttributeError( # pragma: no cover f"{type(self).__name__!r} object has no attribute {__name!r}" diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 388400ed..74614909 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -199,13 +199,14 @@ def ensure_at_least_20(val: int): RetT = TypeVar("RetT") Ts = TypeVarTuple("Ts") -__all__ = ["Signal", "SignalInstance", "_compiled"] +__all__ = ["Signal", "SignalInstance", "_compiled", "ReemissionVal", "Unparametrized"] _NULL = object() F = TypeVar("F", bound=Callable) RECURSION_LIMIT = sys.getrecursionlimit() ReemissionVal = Literal["immediate", "queued", "latest-only"] +Unparametrized = NewType("Unparametrized", object) VALID_REEMISSION = set(ReemissionVal.__args__) # type: ignore DEFAULT_REEMISSION: ReemissionVal = "immediate" @@ -601,310 +602,31 @@ def __repr__(self) -> str: instance = f" on {self.instance!r}" if self.instance is not None else "" return f"<{type(self).__name__}{name}{instance}>" - # ---- BEGIN autgenerated connect overloads @overload def connect( - self: SignalInstance[()], - slot: Callable[[], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1]], - slot: Callable[[], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1]], - slot: Callable[[_T1], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2]], - slot: Callable[[], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2]], - slot: Callable[[_T1], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2]], - slot: Callable[[_T1, _T2], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3]], - slot: Callable[[], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3]], - slot: Callable[[_T1], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3]], - slot: Callable[[_T1, _T2], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3]], - slot: Callable[[_T1, _T2, _T3], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2, _T3], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], - slot: Callable[[], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], - slot: Callable[[_T1], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], - slot: Callable[[_T1, _T2], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], - slot: Callable[[_T1, _T2, _T3], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2, _T3], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], - slot: Callable[[_T1, _T2, _T3, _T4], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], - slot: Callable[[], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], - slot: Callable[[_T1], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], - slot: Callable[[_T1, _T2], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], - slot: Callable[[_T1, _T2, _T3], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2, _T3], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], - slot: Callable[[_T1, _T2, _T3, _T4], RetT], - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... - @overload - def connect( - self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], - slot: Callable[[_T1, _T2, _T3, _T4, _T5], RetT], + self, *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[_T1, _T2, _T3, _T4, _T5], RetT]: ... - - # typing these are hard... we fall back slot: F -> F - # ---- END autgenerated connect overloads + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[F], F]: ... @overload def connect( - self: SignalInstance[GroupSignalInstance], + self, slot: F, *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, ) -> F: ... - # decorator version with no parameters - @overload - def connect( - self, - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[F], F]: ... - # implementation def connect( self, slot: Callable | None = None, @@ -916,7 +638,7 @@ def connect( max_args: int | None = None, on_ref_error: RefErrorChoice = "warn", priority: int = 0, - ) -> Callable: + ) -> F | Callable[[F], F]: """Connect a callback (`slot`) to this signal. `slot` is compatible if: diff --git a/src/psygnal/_signal.py.jinja2 b/src/psygnal/_signal.py.jinja2 deleted file mode 100644 index 267f0c88..00000000 --- a/src/psygnal/_signal.py.jinja2 +++ /dev/null @@ -1,1725 +0,0 @@ -"""The main Signal class and SignalInstance class. - -A note on the "reemission" parameter in Signal and SignalInstances. This controls the -behavior of the signal when a callback emits the signal. - -Since it can be a little confusing, take the following example of a Signal that emits an -integer. We'll connect three callbacks to it, two of which re-emit the same signal with -a different value: - -```python -from psygnal import SignalInstance - -# a signal that emits an integer -sig = SignalInstance((int,), reemission="...") - - -def cb1(value: int) -> None: - print(f"calling cb1 with: {value}") - if value == 1: - # cb1 ALSO triggers an emission of the value 2 - sig.emit(2) - - -def cb2(value: int) -> None: - print(f"calling cb2 with: {value}") - if value == 2: - # cb2 ALSO triggers an emission of the value 3 - sig.emit(3) - - -def cb3(value: int) -> None: - print(f"calling cb3 with: {value}") - - -sig.connect(cb1) -sig.connect(cb2) -sig.connect(cb3) -sig.emit(1) -``` - -with `reemission="queued"` above: you see a breadth-first pattern: -ALL callbacks are called with the first emitted value, before ANY of them are called -with the second emitted value (emitted by the first connected callback cb1) - -``` -calling cb1 with: 1 -calling cb2 with: 1 -calling cb3 with: 1 -calling cb1 with: 2 -calling cb2 with: 2 -calling cb3 with: 2 -calling cb1 with: 3 -calling cb2 with: 3 -calling cb3 with: 3 -``` - -with `reemission='immediate'` signals emitted by callbacks are immediately processed by -all callbacks in a deeper level, before returning back to the original loop level to -call the remaining callbacks with the original value. - -``` -calling cb1 with: 1 -calling cb1 with: 2 -calling cb2 with: 2 -calling cb1 with: 3 -calling cb2 with: 3 -calling cb3 with: 3 -calling cb3 with: 2 -calling cb2 with: 1 -calling cb3 with: 1 -``` - -with `reemission='latest'`, just as with 'immediate', signals emitted by callbacks are -immediately processed by all callbacks in a deeper level. But in this case, the -remaining callbacks in the current level are never called with the original value. - -``` -calling cb1 with: 1 -calling cb1 with: 2 -calling cb2 with: 2 -calling cb1 with: 3 -calling cb2 with: 3 -calling cb3 with: 3 -# cb2 is never called with 1 -# cb3 is never called with 1 or 2 -``` - -The real-world scenario in which this usually arises is an EventedModel or dataclass. -Evented models emit signals on `setattr`: - - -```python -class MyModel(EventedModel): - x: int = 1 - - -m = MyModel(x=1) -print("starting value", m.x) - - -@m.events.x.connect -def ensure_at_least_20(val: int): - print("trying to set to", val) - m.x = max(val, 20) - - -m.x = 5 -print("ending value", m.x) -``` - -``` -starting value 1 -trying to set to 5 -trying to set to 20 -ending value 20 -``` - -With EventedModel.__setattr__, you can easily end up with some complicated recursive -behavior if you connect an on-change callback that also sets the value of the model. In -this case `reemission='latest'` is probably the most appropriate, as it will prevent -the callback from being called with the original (now-stale) value. But one can -conceive of other scenarios where `reemission='immediate'` or `reemission='queued'` -might be more appropriate. Qt's default behavior, for example, is similar to -`immediate`, but can also be configured to be like `queued` by changing the -connection type (in that case, depending on threading). -""" - -from __future__ import annotations - -import inspect -import sys -import threading -import warnings -import weakref -from collections import deque -from contextlib import contextmanager, suppress -from functools import lru_cache, partial, reduce -from inspect import Parameter, Signature, isclass -from typing import ( - TYPE_CHECKING, - Any, - Callable, - ClassVar, - ContextManager, - Final, - Generic, - Iterable, - Iterator, - Literal, - NewType, - NoReturn, - TypeVar, - Union, - cast, - get_args, - get_origin, - get_type_hints, - overload, -) - -from typing_extensions import TypeVarTuple, Unpack - -from ._exceptions import EmitLoopError -from ._mypyc import mypyc_attr -from ._queue import QueuedCallback -from ._weak_callback import ( - StrongFunction, - WeakCallback, - WeakSetattr, - WeakSetitem, - weak_callback, -) - -if TYPE_CHECKING: - from ._group import EmissionInfo - from ._weak_callback import RefErrorChoice - - # single function that does all the work of reducing an iterable of args - # to a single args - ReducerOneArg = Callable[[Iterable[tuple]], tuple] - # function that takes two args tuples. it will be passed to itertools.reduce - ReducerTwoArgs = Callable[[tuple, tuple], tuple] - ReducerFunc = Union[ReducerOneArg, ReducerTwoArgs] - - -# ------ BEGIN Generated TypeVars - -{% for i in range(number_of_types) -%} -_T{{ i+1 }} = TypeVar("_T{{ i+1 }}") -{% endfor %} -# ------ END Generated TypeVars - -GroupSignalInstance = NewType("GroupSignalInstance", object) -RetT = TypeVar("RetT") -Ts = TypeVarTuple("Ts") - -__all__ = ["Signal", "SignalInstance", "_compiled"] - -_NULL = object() -F = TypeVar("F", bound=Callable) -RECURSION_LIMIT = sys.getrecursionlimit() - -ReemissionVal = Literal["immediate", "queued", "latest-only"] -VALID_REEMISSION = set(ReemissionVal.__args__) # type: ignore -DEFAULT_REEMISSION: ReemissionVal = "immediate" - - -# using basic class instead of enum for easier mypyc compatibility -# this isn't exposed publicly anyway. -class ReemissionMode: - """Enumeration of reemission strategies.""" - - IMMEDIATE: Final = "immediate" - QUEUED: Final = "queued" - LATEST: Final = "latest-only" - - @staticmethod - def validate(value: str) -> str: - value = str(value).lower() - if value not in ReemissionMode._members(): - raise ValueError( - f"Invalid reemission value. Must be one of " - f"{', '.join(ReemissionMode._members())}. Not {value!r}" - ) - return value - - @staticmethod - def _members() -> set[str]: - return VALID_REEMISSION - - -class Signal(Generic[Unpack[Ts]]): - """Declares a signal emitter on a class. - - This is class implements the [descriptor - protocol](https://docs.python.org/3/howto/descriptor.html#descriptorhowto) - and is designed to be used as a class attribute, with the supported signature types - provided in the constructor: - - ```python - from psygnal import Signal - - - class MyEmitter: - changed = Signal(int) - - - def receiver(arg: int): - print("new value:", arg) - - - emitter = MyEmitter() - emitter.changed.connect(receiver) - emitter.changed.emit(1) # prints 'new value: 1' - ``` - - !!! note - - in the example above, `MyEmitter.changed` is an instance of `Signal`, - and `emitter.changed` is an instance of `SignalInstance`. See the - documentation on [`SignalInstance`][psygnal.SignalInstance] for details - on how to connect to and/or emit a signal on an instance of an object - that has a `Signal`. - - - Parameters - ---------- - *types : Type[Any] | Signature - A sequence of individual types, or a *single* [`inspect.Signature`][] object. - description : str - Optional descriptive text for the signal. (not used internally). - name : str | None - Optional name of the signal. If it is not specified then the name of the - class attribute that is bound to the signal will be used. default None - check_nargs_on_connect : bool - Whether to check the number of positional args against `signature` when - connecting a new callback. This can also be provided at connection time using - `.connect(..., check_nargs=True)`. By default, `True`. - check_types_on_connect : bool - Whether to check the callback parameter types against `signature` when - connecting a new callback. This can also be provided at connection time using - `.connect(..., check_types=True)`. By default, `False`. - reemission : Literal["immediate", "queued", "latest-only"] | None - Determines the order and manner in which connected callbacks are invoked when a - callback re-emits a signal. Default is `"immediate"`. - - * `"immediate"`: Signals emitted by callbacks are immediately processed in a - deeper emission loop, before returning to process signals emitted at the - current level (after all callbacks in the deeper level have been called). - - * `"queued"`: Signals emitted by callbacks are enqueued for emission after the - current level of emission is complete. This ensures *all* connected - callbacks are called with the first emitted value, before *any* of them are - called with values emitted while calling callbacks. - - * `"latest-only"`: Signals emitted by callbacks are immediately processed in a - deeper emission loop, and remaining callbacks in the current level are never - called with the original value. - """ - - # _signature: Signature # callback signature for this signal - - _current_emitter: ClassVar[SignalInstance | None] = None - - def __init__( - self, - *types: Unpack[Ts], - description: str = "", - name: str | None = None, - check_nargs_on_connect: bool = True, - check_types_on_connect: bool = False, - reemission: ReemissionVal = DEFAULT_REEMISSION, - ) -> None: - if types and isinstance(types[0], Signature): - if len(types) > 1: - warnings.warn( - "Only a single argument is accepted when directly providing a" - f" `Signature`. These args were ignored: {types[1:]}", # type: ignore - stacklevel=2, - ) - types = tuple(x.annotation for x in types[0].parameters.values()) - - self._name = name - self.description = description - self._check_nargs_on_connect = check_nargs_on_connect - self._check_types_on_connect = check_types_on_connect - self._reemission = reemission - self._signal_instance_class: type[SignalInstance] = SignalInstance - self._signal_instance_cache: dict[int, SignalInstance] = {} - self._types = types - - @property - def signature(self) -> Signature: - """[Signature][inspect.Signature] supported by this Signal.""" - return _build_signature(*self._types) - - def __set_name__(self, owner: type[Any], name: str) -> None: - """Set name of signal when declared as a class attribute on `owner`.""" - if self._name is None: - self._name = name - - @overload - def __get__( - self, instance: None, owner: type[Any] | None = None - ) -> Signal[Unpack[Ts]]: ... - - @overload - def __get__( - self, instance: Any, owner: type[Any] | None = None - ) -> SignalInstance[Unpack[Ts]]: ... - - def __get__( - self, instance: Any, owner: type[Any] | None = None - ) -> Signal[Unpack[Ts]] | SignalInstance[Unpack[Ts]]: - """Get signal instance. - - This is called when accessing a Signal instance. If accessed as an - attribute on the class `owner`, instance, will be `None`. Otherwise, - if `instance` is not None, we're being accessed on an instance of `owner`. - - class Emitter: - signal = Signal() - - e = Emitter() - - E.signal # instance will be None, owner will be Emitter - e.signal # instance will be e, owner will be Emitter - - Returns - ------- - Signal or SignalInstance - Depending on how this attribute is accessed. - """ - if instance is None: - return self - if id(instance) in self._signal_instance_cache: - return self._signal_instance_cache[id(instance)] - signal_instance = self._create_signal_instance(instance) - - # cache this signal instance so that future access returns the same instance. - try: - # first, try to assign it to instance.name ... this essentially breaks the - # descriptor, (i.e. __get__ will never again be called for this instance) - # (note, this is the same mechanism used in the `cached_property` decorator) - setattr(instance, cast("str", self._name), signal_instance) - except AttributeError: - # if that fails, which may happen in slotted classes, then we fall back to - # our internal cache - self._cache_signal_instance(instance, signal_instance) - - return signal_instance - - def _cache_signal_instance( - self, instance: Any, signal_instance: SignalInstance - ) -> None: - """Cache a signal instance on the instance.""" - # fallback signal instance cache as last resort. We use the object id - # instead a WeakKeyDictionary because we can't guarantee that the instance - # is hashable or weak-referenceable. and we use a finalize to remove the - # cache when the instance is destroyed (if the object is weak-referenceable). - obj_id = id(instance) - self._signal_instance_cache[obj_id] = signal_instance - with suppress(TypeError): - weakref.finalize(instance, self._signal_instance_cache.pop, obj_id, None) - - def _create_signal_instance( - self, instance: Any, name: str | None = None - ) -> SignalInstance[Unpack[Ts]]: - return self._signal_instance_class( - self._types, - instance=instance, - name=name or self._name, - check_nargs_on_connect=self._check_nargs_on_connect, - check_types_on_connect=self._check_types_on_connect, - reemission=self._reemission, - ) - - @classmethod - @contextmanager - def _emitting(cls, emitter: SignalInstance) -> Iterator[None]: - """Context that sets the sender on a receiver object while emitting a signal.""" - previous, cls._current_emitter = cls._current_emitter, emitter - try: - yield - finally: - cls._current_emitter = previous - - @classmethod - def current_emitter(cls) -> SignalInstance | None: - """Return currently emitting `SignalInstance`, if any. - - This will typically be used in a callback. - - Examples - -------- - ```python - from psygnal import Signal - - - def my_callback(): - source = Signal.current_emitter() - ``` - """ - return cls._current_emitter - - @classmethod - def sender(cls) -> Any: - """Return currently emitting object, if any. - - This will typically be used in a callback. - """ - return getattr(cls._current_emitter, "instance", None) - - -_empty_signature = Signature() - - -@mypyc_attr(allow_interpreted_subclasses=True) -class SignalInstance(Generic[Unpack[Ts]]): - """A signal instance (optionally) bound to an object. - - In most cases, users will not create a `SignalInstance` directly -- instead - creating a [Signal][psygnal.Signal] class attribute. This object will be - instantiated by the `Signal.__get__` method (i.e. the descriptor protocol), - when a `Signal` instance is accessed from an *instance* of a class with `Signal` - attribute. - - However, it is the `SignalInstance` that you will most often be interacting - with when you access the name of a `Signal` on an instance -- so understanding - the `SignalInstance` API is key to using psygnal. - - ```python - class Emitter: - signal = Signal() - - - e = Emitter() - - # when accessed on an *instance* of Emitter, - # the signal attribute will be a SignalInstance - e.signal - - # This is what you will use to connect your callbacks - e.signal.connect(some_callback) - ``` - - Parameters - ---------- - signature : Signature | None - The signature that this signal accepts and will emit, by default `Signature()`. - instance : Any - An object to which this signal is bound. Normally this will be provided by the - `Signal.__get__` method (see above). However, an unbound `SignalInstance` - may also be created directly. by default `None`. - name : str | None - An optional name for this signal. Normally this will be provided by the - `Signal.__get__` method. by default `None` - check_nargs_on_connect : bool - Whether to check the number of positional args against `signature` when - connecting a new callback. This can also be provided at connection time using - `.connect(..., check_nargs=True)`. By default, `True`. - check_types_on_connect : bool - Whether to check the callback parameter types against `signature` when - connecting a new callback. This can also be provided at connection time using - `.connect(..., check_types=True)`. By default, `False`. - reemission : Literal["immediate", "queued", "latest-only"] | None - See docstring for [`Signal`][psygnal.Signal] for details. - By default, `"immediate"`. - - Raises - ------ - TypeError - If `signature` is neither an instance of `inspect.Signature`, or a `tuple` - of types. - """ - - _is_blocked: bool = False - _is_paused: bool = False - _debug_hook: ClassVar[Callable[[EmissionInfo], None] | None] = None - - def __init__( - self, - types: tuple[Unpack[Ts]] | Signature = (), # type: ignore - instance: Any = None, - name: str | None = None, - check_nargs_on_connect: bool = True, - check_types_on_connect: bool = False, - reemission: ReemissionVal = DEFAULT_REEMISSION, - ) -> None: - if isinstance(types, Signature): - types = tuple( - x.annotation - for x in types.parameters.values() - if x.kind is x.POSITIONAL_OR_KEYWORD - ) - if not isinstance(types, (list, tuple, Signature)): - raise TypeError( # pragma: no cover - "`signature` must be either a sequence of types, or an " - "instance of `inspect.Signature`" - ) - - self._reemission = ReemissionMode.validate(reemission) - self._name = name - self._instance: Callable = self._instance_ref(instance) - self._args_queue: list[tuple] = [] # filled when paused - self._types = types - self._check_nargs_on_connect = check_nargs_on_connect - self._check_types_on_connect = check_types_on_connect - self._slots: list[WeakCallback] = [] - self._is_blocked: bool = False - self._is_paused: bool = False - self._lock = threading.RLock() - self._emit_queue: deque[tuple] = deque() - self._recursion_depth: int = 0 - self._max_recursion_depth: int = 0 - self._run_emit_loop_inner: Callable[[], None] - if self._reemission == ReemissionMode.QUEUED: - self._run_emit_loop_inner = self._run_emit_loop_queued - elif self._reemission == ReemissionMode.LATEST: - self._run_emit_loop_inner = self._run_emit_loop_latest_only - else: - self._run_emit_loop_inner = self._run_emit_loop_immediate - - # whether any slots in self._slots have a priority other than 0 - self._priority_in_use = False - - @staticmethod - def _instance_ref(instance: Any) -> Callable[[], Any]: - if instance is None: - return lambda: None - - try: - return weakref.ref(instance) - except TypeError: - # fall back to strong reference if instance is not weak-referenceable - return lambda: instance - - @property - def signature(self) -> Signature: - """Signature supported by this `SignalInstance`.""" - return _build_signature(*self._types) - - @property - def instance(self) -> Any: - """Object that emits this `SignalInstance`.""" - return self._instance() - - @property - def name(self) -> str: - """Name of this `SignalInstance`.""" - return self._name or "" - - def __repr__(self) -> str: - """Return repr.""" - name = f" {self._name!r}" if self._name else "" - instance = f" on {self.instance!r}" if self.instance is not None else "" - return f"<{type(self).__name__}{name}{instance}>" - - # ---- BEGIN autgenerated connect overloads - {%- for sig in connect_overloads %} - @overload - def connect( - {% for arg in sig.arguments -%} - {{ arg.name }}: {{ arg.hint }}, - {% endfor %} - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> {{ sig.return_hint }}: ... - {%- endfor %} - # ---- END autgenerated connect overloads - - # typing these are hard... we fall back slot: F -> F - @overload - def connect( - self: SignalInstance[GroupSignalInstance], - slot: F, - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> F: ... - # decorator version with no parameters - @overload - def connect( - self, - *, - thread: threading.Thread | Literal["main", "current"] | None = ..., - check_nargs: bool | None = ..., - check_types: bool | None = ..., - unique: bool | str = ..., - max_args: int | None = None, - on_ref_error: RefErrorChoice = ..., - priority: int = ..., - ) -> Callable[[F], F]: ... - # implementation - def connect( - self, - slot: Callable | None = None, - *, - thread: threading.Thread | Literal["main", "current"] | None = None, - check_nargs: bool | None = None, - check_types: bool | None = None, - unique: bool | str = False, - max_args: int | None = None, - on_ref_error: RefErrorChoice = "warn", - priority: int = 0, - ) -> Callable: - """Connect a callback (`slot`) to this signal. - - `slot` is compatible if: - - * it requires no more than the number of positional arguments emitted by this - `SignalInstance`. (It *may* require less) - * it has no *required* keyword arguments (keyword only arguments that have - no default). - * if `check_types` is `True`, the parameter types in the callback signature must - match the signature of this `SignalInstance`. - - This method may be used as a decorator. - - ```python - @signal.connect - def my_function(): ... - ``` - - !!!important - If a signal is connected with `thread != None`, then it is up to the user - to ensure that `psygnal.emit_queued` is called, or that one of the backend - convenience functions is used (e.g. `psygnal.qt.start_emitting_from_queue`). - Otherwise, callbacks that are connected to signals that are emitted from - another thread will never be called. - - Parameters - ---------- - slot : Callable - A callable to connect to this signal. If the callable accepts less - arguments than the signature of this slot, then they will be discarded when - calling the slot. - check_nargs : Optional[bool] - If `True` and the provided `slot` requires more positional arguments than - the signature of this Signal, raise `TypeError`. by default `True`. - thread: Thread | Literal["main", "current"] | None - If `None` (the default), this slot will be invoked immediately when a signal - is emitted, from whatever thread emitted the signal. If a thread object is - provided, then the callback will only be immediately invoked if the signal - is emitted from that thread. Otherwise, the callback will be added to a - queue. **Note!**, when using the `thread` parameter, the user is responsible - for calling `psygnal.emit_queued()` in the corresponding thread, otherwise - the slot will never be invoked. (See note above). (The strings `"main"` and - `"current"` are also accepted, and will be interpreted as the - `threading.main_thread()` and `threading.current_thread()`, respectively). - check_types : Optional[bool] - If `True`, An additional check will be performed to make sure that types - declared in the slot signature are compatible with the signature - declared by this signal, by default `False`. - unique : Union[bool, str, None] - If `True`, returns without connecting if the slot has already been - connected. If the literal string "raise" is passed to `unique`, then a - `ValueError` will be raised if the slot is already connected. - By default `False`. - max_args : Optional[int] - If provided, `slot` will be called with no more more than `max_args` when - this SignalInstance is emitted. (regardless of how many arguments are - emitted). - on_ref_error : {'raise', 'warn', 'ignore'}, optional - What to do if a weak reference cannot be created. If 'raise', a - ReferenceError will be raised. If 'warn' (default), a warning will be - issued and a strong-reference will be used. If 'ignore' a strong-reference - will be used (silently). - priority : int - The priority of the callback. This is used to determine the order in which - callbacks are called when multiple are connected to the same signal. - Higher priority callbacks are called first. Negative values are allowed. - The default is 0. - - Raises - ------ - TypeError - If a non-callable object is provided. - ValueError - If the provided slot fails validation, either due to mismatched positional - argument requirements, or failed type checking. - ValueError - If `unique` is `True` and `slot` has already been connected. - """ - if check_nargs is None: - check_nargs = self._check_nargs_on_connect - if check_types is None: - check_types = self._check_types_on_connect - - def _wrapper( - slot: F, - max_args: int | None = max_args, - _on_ref_err: RefErrorChoice = on_ref_error, - ) -> F: - if not callable(slot): - raise TypeError(f"Cannot connect to non-callable object: {slot}") - - with self._lock: - if unique and slot in self: - if unique == "raise": - raise ValueError( - "Slot already connect. Use `connect(..., unique=False)` " - "to allow duplicate connections" - ) - return slot - - slot_sig: Signature | None = None - if check_nargs and (max_args is None): - slot_sig, max_args, isqt = self._check_nargs(slot, self.signature) - if isqt: - _on_ref_err = "ignore" - if check_types: - slot_sig = slot_sig or signature(slot) - if not _parameter_types_match(slot, self.signature, slot_sig): - extra = f"- Slot types {slot_sig} do not match types in signal." - self._raise_connection_error(slot, extra) - - cb = weak_callback( - slot, - max_args=max_args, - finalize=self._try_discard, - on_ref_error=_on_ref_err, - priority=priority, - ) - if thread is not None: - cb = QueuedCallback(cb, thread=thread) - self._append_slot(cb) - return slot - - return _wrapper if slot is None else _wrapper(slot) # type: ignore - - def _append_slot(self, slot: WeakCallback) -> None: - """Append a slot to the list of slots. - - Implementing this as a method allows us to override/extend it in subclasses. - """ - # if no previously connected slots have a priority, and this slot also - # has no priority, we can just (quickly) append it to the end of the list. - if not self._priority_in_use: - if not slot.priority: - self._slots.append(slot) - return - # remember that we have a priority in use, so we skip this check - self._priority_in_use = True - - # otherwise we need to (slowly) iterate over self._slots to - # insert the slot in the correct position based on priority. - # High priority slots are placed at the front of the list - # low/negative priority slots are at the end of the list - for i, s in enumerate(self._slots): - if s.priority < slot.priority: - self._slots.insert(i, slot) - return - self._slots.append(slot) - - def _remove_slot(self, slot: Literal["all"] | int | WeakCallback) -> None: - """Remove a slot from the list of slots.""" - # implementing this as a method allows us to override/extend it in subclasses - if slot == "all": - self._slots.clear() - elif isinstance(slot, int): - self._slots.pop(slot) - else: - self._slots.remove(cast("WeakCallback", slot)) - - def _try_discard(self, callback: WeakCallback, missing_ok: bool = True) -> None: - """Try to discard a callback from the list of slots. - - Parameters - ---------- - callback : WeakCallback - A callback to discard. - missing_ok : bool, optional - If `True`, do not raise an error if the callback is not found in the list. - """ - try: - self._remove_slot(callback) - except ValueError: - if not missing_ok: - raise - - def connect_setattr( - self, - obj: object, - attr: str, - maxargs: int | None | object = _NULL, - *, - on_ref_error: RefErrorChoice = "warn", - priority: int = 0, - ) -> WeakCallback[None]: - """Bind an object attribute to the emitted value of this signal. - - Equivalent to calling `self.connect(functools.partial(setattr, obj, attr))`, - but with additional weakref safety (i.e. a strong reference to `obj` will not - be retained). The return object can be used to - [`disconnect()`][psygnal.SignalInstance.disconnect], (or you can use - [`disconnect_setattr()`][psygnal.SignalInstance.disconnect_setattr]). - - Parameters - ---------- - obj : object - An object. - attr : str - The name of an attribute on `obj` that should be set to the value of this - signal when emitted. - maxargs : Optional[int] - max number of positional args to accept - on_ref_error: {'raise', 'warn', 'ignore'}, optional - What to do if a weak reference cannot be created. If 'raise', a - ReferenceError will be raised. If 'warn' (default), a warning will be - issued and a strong-reference will be used. If 'ignore' a strong-reference - will be used (silently). - priority : int - The priority of the callback. This is used to determine the order in which - callbacks are called when multiple are connected to the same signal. - Higher priority callbacks are called first. Negative values are allowed. - The default is 0. - - Returns - ------- - Tuple - (weakref.ref, name, callable). Reference to the object, name of the - attribute, and setattr closure. Can be used to disconnect the slot. - - Raises - ------ - ValueError - If this is not a single-value signal - AttributeError - If `obj` has no attribute `attr`. - - Examples - -------- - >>> class T: - ... sig = Signal(int) - >>> class SomeObj: - ... x = 1 - >>> t = T() - >>> my_obj = SomeObj() - >>> t.sig.connect_setattr(my_obj, "x") - >>> t.sig.emit(5) - >>> assert my_obj.x == 5 - """ - if maxargs is _NULL: - warnings.warn( - "The default value of maxargs will change from `None` to `1` in " - "version 0.11. To silence this warning, provide an explicit value for " - "maxargs (`None` for current behavior, `1` for future behavior).", - FutureWarning, - stacklevel=2, - ) - maxargs = None - - if not hasattr(obj, attr): - raise AttributeError(f"Object {obj} has no attribute {attr!r}") - - with self._lock: - caller = WeakSetattr( - obj, - attr, - max_args=cast("int | None", maxargs), - finalize=self._try_discard, - on_ref_error=on_ref_error, - priority=priority, - ) - self._append_slot(caller) - return caller - - def disconnect_setattr( - self, obj: object, attr: str, missing_ok: bool = True - ) -> None: - """Disconnect a previously connected attribute setter. - - Parameters - ---------- - obj : object - An object. - attr : str - The name of an attribute on `obj` that was previously used for - `connect_setattr`. - missing_ok : bool - If `False` and the provided `slot` is not connected, raises `ValueError`. - by default `True` - - Raises - ------ - ValueError - If `missing_ok` is `True` and no attribute setter is connected. - """ - with self._lock: - cb = WeakSetattr(obj, attr, on_ref_error="ignore") - self._try_discard(cb, missing_ok) - - def connect_setitem( - self, - obj: object, - key: str, - maxargs: int | None | object = _NULL, - *, - on_ref_error: RefErrorChoice = "warn", - priority: int = 0, - ) -> WeakCallback[None]: - """Bind a container item (such as a dict key) to emitted value of this signal. - - Equivalent to calling `self.connect(functools.partial(obj.__setitem__, attr))`, - but with additional weakref safety (i.e. a strong reference to `obj` will not - be retained). The return object can be used to - [`disconnect()`][psygnal.SignalInstance.disconnect], (or you can use - [`disconnect_setitem()`][psygnal.SignalInstance.disconnect_setitem]). - - Parameters - ---------- - obj : object - An object. - key : str - Name of the key in `obj` that should be set to the value of this - signal when emitted - maxargs : Optional[int] - max number of positional args to accept - on_ref_error: {'raise', 'warn', 'ignore'}, optional - What to do if a weak reference cannot be created. If 'raise', a - ReferenceError will be raised. If 'warn' (default), a warning will be - issued and a strong-reference will be used. If 'ignore' a strong-reference - will be used (silently). - priority : int - The priority of the callback. This is used to determine the order in which - callbacks are called when multiple are connected to the same signal. - Higher priority callbacks are called first. Negative values are allowed. - The default is 0. - - Returns - ------- - Tuple - (weakref.ref, name, callable). Reference to the object, name of the - attribute, and setitem closure. Can be used to disconnect the slot. - - Raises - ------ - ValueError - If this is not a single-value signal - TypeError - If `obj` does not support __setitem__. - - Examples - -------- - >>> class T: - ... sig = Signal(int) - >>> t = T() - >>> my_obj = dict() - >>> t.sig.connect_setitem(my_obj, "x") - >>> t.sig.emit(5) - >>> assert my_obj == {"x": 5} - """ - if maxargs is _NULL: - warnings.warn( - "The default value of maxargs will change from `None` to `1` in" - "version 0.11. To silence this warning, provide an explicit value for " - "maxargs (`None` for current behavior, `1` for future behavior).", - FutureWarning, - stacklevel=2, - ) - maxargs = None - - if not hasattr(obj, "__setitem__"): - raise TypeError(f"Object {obj} does not support __setitem__") - - with self._lock: - caller = WeakSetitem( - obj, - key, - max_args=cast("int | None", maxargs), - finalize=self._try_discard, - on_ref_error=on_ref_error, - priority=priority, - ) - self._append_slot(caller) - - return caller - - def disconnect_setitem( - self, obj: object, key: str, missing_ok: bool = True - ) -> None: - """Disconnect a previously connected item setter. - - Parameters - ---------- - obj : object - An object. - key : str - The name of a key in `obj` that was previously used for - `connect_setitem`. - missing_ok : bool - If `False` and the provided `slot` is not connected, raises `ValueError`. - by default `True` - - Raises - ------ - ValueError - If `missing_ok` is `True` and no item setter is connected. - """ - if not hasattr(obj, "__setitem__"): - raise TypeError(f"Object {obj} does not support __setitem__") - - with self._lock: - caller = WeakSetitem(obj, key, on_ref_error="ignore") - self._try_discard(caller, missing_ok) - - def _check_nargs( - self, slot: Callable, spec: Signature - ) -> tuple[Signature | None, int | None, bool]: - """Make sure slot is compatible with signature. - - Also returns the maximum number of arguments that we can pass to the slot - - Returns - ------- - slot_sig : Signature | None - The signature of the slot, or None if it could not be determined. - maxargs : int | None - The maximum number of arguments that we can pass to the slot. - is_qt : bool - Whether the slot is a Qt slot. - """ - try: - slot_sig = _get_signature_possibly_qt(slot) - except ValueError as e: - warnings.warn( - f"{e}. To silence this warning, connect with " "`check_nargs=False`", - stacklevel=2, - ) - return None, None, False - try: - minargs, maxargs = _acceptable_posarg_range(slot_sig) - except ValueError as e: - if isinstance(slot, partial): - raise ValueError( - f"{e}. (Note: prefer using positional args with " - "functools.partials when possible)." - ) from e - raise - - # if `slot` requires more arguments than we will provide, raise. - if minargs > (n_spec_params := len(spec.parameters)): - extra = ( - f"- Slot requires at least {minargs} positional " - f"arguments, but spec only provides {n_spec_params}" - ) - self._raise_connection_error(slot, extra) - - return None if isinstance(slot_sig, str) else slot_sig, maxargs, True - - def _raise_connection_error(self, slot: Callable, extra: str = "") -> NoReturn: - name = getattr(slot, "__name__", str(slot)) - msg = f"Cannot connect slot {name!r} with signature: {signature(slot)}:\n" - msg += extra - msg += f"\n\nAccepted signature: {self.signature}" - raise ValueError(msg) - - def _slot_index(self, slot: Callable) -> int: - """Get index of `slot` in `self._slots`. Return -1 if not connected.""" - with self._lock: - normed = weak_callback(slot, on_ref_error="ignore") - # NOTE: - # the == method here relies on the __eq__ method of each SlotCaller subclass - return next((i for i, s in enumerate(self._slots) if s == normed), -1) - - def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> None: - """Disconnect slot from signal. - - Parameters - ---------- - slot : callable, optional - The specific slot to disconnect. If `None`, all slots will be disconnected, - by default `None` - missing_ok : Optional[bool] - If `False` and the provided `slot` is not connected, raises `ValueError. - by default `True` - - Raises - ------ - ValueError - If `slot` is not connected and `missing_ok` is False. - """ - with self._lock: - if slot is None: - # NOTE: clearing an empty list is actually a RuntimeError in Qt - self._remove_slot("all") - return - - idx = self._slot_index(slot) - if idx != -1: - self._remove_slot(idx) - elif not missing_ok: - raise ValueError(f"slot is not connected: {slot}") - - def __contains__(self, slot: Callable) -> bool: - """Return `True` if slot is connected.""" - return self._slot_index(slot) >= 0 - - def __len__(self) -> int: - """Return number of connected slots.""" - return len(self._slots) - - def emit( - self, *args: Any, check_nargs: bool = False, check_types: bool = False - ) -> None: - """Emit this signal with arguments `args`. - - !!! note - - `check_args` and `check_types` both add overhead when calling emit. - - Parameters - ---------- - *args : Any - These arguments will be passed when calling each slot (unless the slot - accepts fewer arguments, in which case extra args will be discarded.) - check_nargs : bool - If `False` and the provided arguments cannot be successfully bound to the - signature of this Signal, raise `TypeError`. Incurs some overhead. - by default False. - check_types : bool - If `False` and the provided arguments do not match the types declared by - the signature of this Signal, raise `TypeError`. Incurs some overhead. - by default False. - - Raises - ------ - TypeError - If `check_nargs` and/or `check_types` are `True`, and the corresponding - checks fail. - """ - if self._is_blocked: - return - - if check_nargs: - try: - self.signature.bind(*args) - except TypeError as e: - raise TypeError( - f"Cannot emit args {args} from signal {self!r} with " - f"signature {self.signature}:\n{e}" - ) from e - - if check_types and not _parameter_types_match( - lambda: None, self.signature, _build_signature(*[type(a) for a in args]) - ): - raise TypeError( - f"Types provided to '{self.name}.emit' " - f"{tuple(type(a).__name__ for a in args)} do not match signal " - f"signature: {self.signature}" - ) - - if self._is_paused: - self._args_queue.append(args) - return - - if SignalInstance._debug_hook is not None: - from ._group import EmissionInfo - - SignalInstance._debug_hook(EmissionInfo(self, args)) - - self._run_emit_loop(args) - - def __call__( - self, *args: Any, check_nargs: bool = False, check_types: bool = False - ) -> None: - """Alias for `emit()`. But prefer using `emit()` for clarity.""" - return self.emit(*args, check_nargs=check_nargs, check_types=check_types) - - def _run_emit_loop(self, args: tuple[Any, ...]) -> None: - with self._lock: - self._emit_queue.append(args) - if len(self._emit_queue) > 1: - return - try: - # allow receiver to query sender with Signal.current_emitter() - self._recursion_depth += 1 - self._max_recursion_depth = max( - self._max_recursion_depth, self._recursion_depth - ) - with Signal._emitting(self): - self._run_emit_loop_inner() - except RecursionError as e: - raise RecursionError( - f"RecursionError when " - f"emitting signal {self.name!r} with args {args}" - ) from e - except Exception as cb_err: - if isinstance(cb_err, EmitLoopError): - raise cb_err - loop_err = EmitLoopError( - exc=cb_err, - signal=self, - recursion_depth=self._recursion_depth - 1, - reemission=self._reemission, - emit_queue=self._emit_queue, - ).with_traceback(cb_err.__traceback__) - # this comment will show up in the traceback - raise loop_err from cb_err # emit() call ABOVE || callback error BELOW - finally: - self._recursion_depth -= 1 - # we're back to the root level of the emit loop, reset max_depth - if self._recursion_depth <= 0: - self._max_recursion_depth = 0 - self._recursion_depth = 0 - self._emit_queue.clear() - - def _run_emit_loop_immediate(self) -> None: - args = self._emit_queue.popleft() - for caller in self._slots: - caller.cb(args) - - def _run_emit_loop_latest_only(self) -> None: - self._args = args = self._emit_queue.popleft() - for caller in self._slots: - if self._recursion_depth < self._max_recursion_depth: - # we've already entered a deeper emit loop - # we should drop the remaining slots in this round and return - break - self._caller = caller - caller.cb(args) - - def _run_emit_loop_queued(self) -> None: - i = 0 - while i < len(self._emit_queue): - args = self._emit_queue[i] - for caller in self._slots: - caller.cb(args) - if len(self._emit_queue) > RECURSION_LIMIT: - raise RecursionError - i += 1 - - def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: - """Block this signal from emitting. - - NOTE: the `exclude` argument is only for SignalGroup subclass, but we - have to include it here to make mypyc happy. - """ - self._is_blocked = True - - def unblock(self) -> None: - """Unblock this signal, allowing it to emit.""" - self._is_blocked = False - - def blocked(self) -> ContextManager[None]: - """Context manager to temporarily block this signal. - - Useful if you need to temporarily block all emission of a given signal, - (for example, to avoid a recursive signal loop) - - Examples - -------- - ```python - class MyEmitter: - changed = Signal() - - def make_a_change(self): - self.changed.emit() - - obj = MyEmitter() - - with obj.changed.blocked() - obj.make_a_change() # will NOT emit a changed signal. - ``` - """ - return _SignalBlocker(self) - - def pause(self) -> None: - """Pause all emission and collect *args tuples from emit(). - - args passed to `emit` will be collected and re-emitted when `resume()` is - called. For a context manager version, see `paused()`. - """ - self._is_paused = True - - def resume(self, reducer: ReducerFunc | None = None, initial: Any = _NULL) -> None: - """Resume (unpause) this signal, emitting everything in the queue. - - Parameters - ---------- - reducer : Callable | None - A optional function to reduce the args collected while paused into a single - emitted group of args. If not provided, all emissions will be re-emitted - as they were collected when the signal is resumed. May be: - - - a function that takes two args tuples and returns a single args tuple. - This will be passed to `functools.reduce` and is expected to reduce all - collected/emitted args into a single tuple. - For example, three `emit(1)` events would be reduced and re-emitted as - follows: `self.emit(*functools.reduce(reducer, [(1,), (1,), (1,)]))` - - a function that takes a single argument (an iterable of args tuples) and - returns a tuple (the reduced args). This will be *not* be passed to - `functools.reduce`. If `reducer` is a function that takes a single - argument, `initial` will be ignored. - initial: any, optional - initial value to pass to `functools.reduce` - - Examples - -------- - >>> class T: - ... sig = Signal(int) - >>> t = T() - >>> t.sig.pause() - >>> t.sig.emit(1) - >>> t.sig.emit(2) - >>> t.sig.emit(3) - >>> t.sig.resume(lambda a, b: (a[0].union(set(b)),), (set(),)) - >>> # results in t.sig.emit({1, 2, 3}) - """ - self._is_paused = False - # not sure why this attribute wouldn't be set, but when resuming in - # EventedModel.update, it may be undefined (as seen in tests) - if not getattr(self, "_args_queue", None): - return - if len(self._slots) == 0: - self._args_queue.clear() - return - - if reducer is not None: - if len(inspect.signature(reducer).parameters) == 1: - args = cast("ReducerOneArg", reducer)(self._args_queue) - else: - reducer = cast("ReducerTwoArgs", reducer) - if initial is _NULL: - args = reduce(reducer, self._args_queue) - else: - args = reduce(reducer, self._args_queue, initial) - self._run_emit_loop(args) - else: - for args in self._args_queue: - self._run_emit_loop(args) - self._args_queue.clear() - - def paused( - self, reducer: ReducerFunc | None = None, initial: Any = _NULL - ) -> ContextManager[None]: - """Context manager to temporarily pause this signal. - - Parameters - ---------- - reducer : Callable | None - A optional function to reduce the args collected while paused into a single - emitted group of args. If not provided, all emissions will be re-emitted - as they were collected when the signal is resumed. May be: - - - a function that takes two args tuples and returns a single args tuple. - This will be passed to `functools.reduce` and is expected to reduce all - collected/emitted args into a single tuple. - For example, three `emit(1)` events would be reduced and re-emitted as - follows: `self.emit(*functools.reduce(reducer, [(1,), (1,), (1,)]))` - - a function that takes a single argument (an iterable of args tuples) and - returns a tuple (the reduced args). This will be *not* be passed to - `functools.reduce`. If `reducer` is a function that takes a single - argument, `initial` will be ignored. - initial: any, optional - initial value to pass to `functools.reduce` - - Examples - -------- - >>> with obj.signal.paused(lambda a, b: (a[0].union(set(b)),), (set(),)): - ... t.sig.emit(1) - ... t.sig.emit(2) - ... t.sig.emit(3) - >>> # results in obj.signal.emit({1, 2, 3}) - """ - return _SignalPauser(self, reducer, initial) - - def __getstate__(self) -> dict: - """Return dict of current state, for pickle.""" - attrs = ( - "_types", - "_name", - "_is_blocked", - "_is_paused", - "_args_queue", - "_check_nargs_on_connect", - "_check_types_on_connect", - "_emit_queue", - "_priority_in_use", - "_reemission", - "_max_recursion_depth", - "_recursion_depth", - ) - dd = {slot: getattr(self, slot) for slot in attrs} - dd["_instance"] = self._instance() - dd["_slots"] = [x for x in self._slots if isinstance(x, StrongFunction)] - if len(self._slots) > len(dd["_slots"]): - warnings.warn( - "Pickling a SignalInstance does not copy connected weakly referenced " - "slots.", - stacklevel=2, - ) - - return dd - - def __setstate__(self, state: dict) -> None: - """Restore state from pickle.""" - # don't use __dict__, mypyc doesn't have it - for k, v in state.items(): - if k == "_instance": - self._instance = self._instance_ref(v) - else: - setattr(self, k, v) - self._lock = threading.RLock() - if self._reemission == ReemissionMode.QUEUED: # pragma: no cover - self._run_emit_loop_inner = self._run_emit_loop_queued - elif self._reemission == ReemissionMode.LATEST: # pragma: no cover - self._run_emit_loop_inner = self._run_emit_loop_latest_only - else: - self._run_emit_loop_inner = self._run_emit_loop_immediate - - -class _SignalBlocker: - """Context manager to block and unblock a signal.""" - - def __init__( - self, signal: SignalInstance, exclude: Iterable[str | SignalInstance] = () - ) -> None: - self._signal = signal - self._exclude = exclude - self._was_blocked = signal._is_blocked - - def __enter__(self) -> None: - self._signal.block(exclude=self._exclude) - - def __exit__(self, *args: Any) -> None: - if not self._was_blocked: - self._signal.unblock() - - -class _SignalPauser: - """Context manager to pause and resume a signal.""" - - def __init__( - self, signal: SignalInstance, reducer: ReducerFunc | None, initial: Any - ) -> None: - self._was_paused = signal._is_paused - self._signal = signal - self._reducer = reducer - self._initial = initial - - def __enter__(self) -> None: - self._signal.pause() - - def __exit__(self, *args: Any) -> None: - if not self._was_paused: - self._signal.resume(self._reducer, self._initial) - - -# ############################################################################# -# ############################################################################# - - -def signature(obj: Any) -> inspect.Signature: - try: - return inspect.signature(obj) - except ValueError as e: - with suppress(Exception): - if not inspect.ismethod(obj): - return _stub_sig(obj) - raise e from e - - -_ANYSIG = Signature( - [ - Parameter(name="args", kind=Parameter.VAR_POSITIONAL), - Parameter(name="kwargs", kind=Parameter.VAR_KEYWORD), - ] -) - - -@lru_cache(maxsize=None) -def _stub_sig(obj: Any) -> Signature: - """Called as a backup when inspect.signature fails.""" - import builtins - - # this nonsense is here because it's hard to get the signature of mypyc-compiled - # objects, but we still want to be able to connect a signal instance. - if ( - type(getattr(obj, "__self__", None)) is SignalInstance - and getattr(obj, "__name__", None) == "emit" - ) or type(obj) is SignalInstance: - # we won't reach this in testing because - # Compiled functions don't trigger profiling and tracing hooks - return _ANYSIG # pragma: no cover - - # just a common case - if obj is builtins.print: - params = [ - Parameter(name="value", kind=Parameter.VAR_POSITIONAL), - Parameter(name="sep", kind=Parameter.KEYWORD_ONLY, default=" "), - Parameter(name="end", kind=Parameter.KEYWORD_ONLY, default="\n"), - Parameter(name="file", kind=Parameter.KEYWORD_ONLY, default=None), - Parameter(name="flush", kind=Parameter.KEYWORD_ONLY, default=False), - ] - return Signature(params) - raise ValueError("unknown object") - - -def _build_signature(*types: Any) -> Signature: - params = [ - Parameter(name=f"p{i}", kind=Parameter.POSITIONAL_ONLY, annotation=t) - for i, t in enumerate(types) - ] - return Signature(params) - - -# def f(a, /, b, c=None, *d, f=None, **g): print(locals()) -# -# a: kind=POSITIONAL_ONLY, default=Parameter.empty # 1 required posarg -# b: kind=POSITIONAL_OR_KEYWORD, default=Parameter.empty # 1 requires posarg -# c: kind=POSITIONAL_OR_KEYWORD, default=None # 1 optional posarg -# d: kind=VAR_POSITIONAL, default=Parameter.empty # N optional posargs -# e: kind=KEYWORD_ONLY, default=Parameter.empty # 1 REQUIRED kwarg -# f: kind=KEYWORD_ONLY, default=None # 1 optional kwarg -# g: kind=VAR_KEYWORD, default=Parameter.empty # N optional kwargs - - -def _get_signature_possibly_qt(slot: Callable) -> Signature | str: - # checking qt has to come first, since the signature of the emit method - # of a Qt SignalInstance is just None> - # https://bugreports.qt.io/browse/PYSIDE-1713 - sig = _guess_qtsignal_signature(slot) - return signature(slot) if sig is None else sig - - -def _acceptable_posarg_range( - sig: Signature | str, forbid_required_kwarg: bool = True -) -> tuple[int, int | None]: - """Return tuple of (min, max) accepted positional arguments. - - Parameters - ---------- - sig : Signature - Signature object to evaluate - forbid_required_kwarg : Optional[bool] - Whether to allow required KEYWORD_ONLY parameters. by default True. - - Returns - ------- - arg_range : Tuple[int, int] - minimum, maximum number of acceptable positional arguments - - Raises - ------ - ValueError - If the signature has a required keyword_only parameter and - `forbid_required_kwarg` is `True`. - """ - if isinstance(sig, str): - if "(" not in sig: # pragma: no cover - raise ValueError(f"Unrecognized string signature format: {sig!r}") - inner = sig.split("(", 1)[1].split(")", 1)[0] - minargs = maxargs = inner.count(",") + 1 if inner else 0 - return minargs, maxargs - - required = 0 - optional = 0 - posargs_unlimited = False - _pos_required = {Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD} - for param in sig.parameters.values(): - if param.kind in _pos_required: - if param.default is Parameter.empty: - required += 1 - else: - optional += 1 - elif param.kind is Parameter.VAR_POSITIONAL: - posargs_unlimited = True - elif ( - param.kind is Parameter.KEYWORD_ONLY - and param.default is Parameter.empty - and forbid_required_kwarg - ): - raise ValueError(f"Unsupported KEYWORD_ONLY parameters in signature: {sig}") - return (required, None if posargs_unlimited else required + optional) - - -def _parameter_types_match( - function: Callable, spec: Signature, func_sig: Signature | None = None -) -> bool: - """Return True if types in `function` signature match those in `spec`. - - Parameters - ---------- - function : Callable - A function to validate - spec : Signature - The Signature against which the `function` should be validated. - func_sig : Signature, optional - Signature for `function`, if `None`, signature will be inspected. - by default None - - Returns - ------- - bool - True if the parameter types match. - """ - fsig = func_sig or signature(function) - - func_hints: dict | None = None - for f_param, spec_param in zip(fsig.parameters.values(), spec.parameters.values()): - f_anno = f_param.annotation - if f_anno is fsig.empty: - # if function parameter is not type annotated, allow it. - continue - - if isinstance(f_anno, str): - if func_hints is None: - func_hints = get_type_hints(function) - f_anno = func_hints.get(f_param.name) - - if not _is_subclass(f_anno, spec_param.annotation): - return False - return True - - -def _is_subclass(left: type[Any], right: type) -> bool: - """Variant of issubclass with support for unions.""" - if not isclass(left) and get_origin(left) is Union: - return any(issubclass(i, right) for i in get_args(left)) - return issubclass(left, right) - - -def _guess_qtsignal_signature(obj: Any) -> str | None: - """Return string signature if `obj` is a SignalInstance or Qt emit method. - - This is a bit of a hack, but we found no better way: - https://stackoverflow.com/q/69976089/1631624 - https://bugreports.qt.io/browse/PYSIDE-1713 - """ - # on my machine, this takes ~700ns on PyQt5 and 8.7µs on PySide2 - type_ = type(obj) - if "pyqtBoundSignal" in type_.__name__: - return cast("str", obj.signal) - qualname = getattr(obj, "__qualname__", "") - if qualname == "pyqtBoundSignal.emit": - return cast("str", obj.__self__.signal) - - # note: this IS all actually covered in tests... but only in the Qt tests, - # so it (annoyingly) briefly looks like it fails coverage. - if qualname == "SignalInstance.emit" and type_.__name__.startswith("builtin"): - # we likely have the emit method of a SignalInstance - # call it with ridiculous params to get the err - return _ridiculously_call_emit(obj.__self__.emit) # pragma: no cover - if "SignalInstance" in type_.__name__ and "QtCore" in getattr( - type_, "__module__", "" - ): # pragma: no cover - return _ridiculously_call_emit(obj.emit) - return None - - -_CRAZY_ARGS = (1,) * 255 - - -# note: this IS all actually covered in tests... but only in the Qt tests, -# so it (annoyingly) briefly looks like it fails coverage. -def _ridiculously_call_emit(emitter: Any) -> str | None: # pragma: no cover - """Call SignalInstance emit() to get the signature from err message.""" - try: - emitter(*_CRAZY_ARGS) - except TypeError as e: - if "only accepts" in str(e): - return str(e).split("only accepts")[0].strip() - return None # pragma: no cover - - -_compiled: bool - - -def __getattr__(name: str) -> Any: - if name == "_compiled": - return hasattr(Signal, "__mypyc_attrs__") - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/psygnal/_signal.pyi b/src/psygnal/_signal.pyi new file mode 100644 index 00000000..50d4876b --- /dev/null +++ b/src/psygnal/_signal.pyi @@ -0,0 +1,1388 @@ +import threading +from inspect import Signature +from typing import ( + Any, + Callable, + ClassVar, + ContextManager, + Final, + Generic, + Iterable, + Iterator, + Literal, + NewType, + NoReturn, + TypeVar, + overload, +) + +from _typeshed import Incomplete +from typing_extensions import TypeVarTuple, Unpack + +from ._group import EmissionInfo +from ._weak_callback import RefErrorChoice, WeakCallback + +__all__ = ["Signal", "SignalInstance", "_compiled", "ReemissionVal", "Unparametrized"] + +ReducerOneArg = Callable[[Iterable[tuple]], tuple] +ReducerTwoArgs = Callable[[tuple, tuple], tuple] +ReducerFunc = ReducerOneArg | ReducerTwoArgs +_T1 = TypeVar("_T1") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") +_T4 = TypeVar("_T4") +_T5 = TypeVar("_T5") +RetT = TypeVar("RetT") +Ts = TypeVarTuple("Ts") +F = TypeVar("F", bound=Callable) +ReemissionVal = Literal["immediate", "queued", "latest-only"] +Unparametrized = NewType("Unparametrized", object) + +class ReemissionMode: + IMMEDIATE: Final[str] + QUEUED: Final[str] + LATEST: Final[str] + @staticmethod + def validate(value: str) -> str: ... + @staticmethod + def _members() -> set[str]: ... + +class Signal(Generic[Unpack[Ts]]): + _current_emitter: ClassVar[SignalInstance | None] + _name: Incomplete + description: Incomplete + _check_nargs_on_connect: Incomplete + _check_types_on_connect: Incomplete + _reemission: Incomplete + _signal_instance_class: Incomplete + _signal_instance_cache: Incomplete + _types: Incomplete + def __init__( + self, + *types: Unpack[Ts], + description: str = "", + name: str | None = None, + check_nargs_on_connect: bool = True, + check_types_on_connect: bool = False, + reemission: ReemissionVal = ..., + ) -> None: ... + @property + def signature(self) -> Signature: ... + def __set_name__(self, owner: type[Any], name: str) -> None: ... + @overload + def __get__( + self, instance: None, owner: type[Any] | None = None + ) -> Signal[Unpack[Ts]]: ... + @overload + def __get__( + self, instance: Any, owner: type[Any] | None = None + ) -> SignalInstance[Unpack[Ts]]: ... + def _cache_signal_instance( + self, instance: Any, signal_instance: SignalInstance + ) -> None: ... + def _create_signal_instance( + self, instance: Any, name: str | None = None + ) -> SignalInstance[Unpack[Ts]]: ... + @classmethod + def _emitting(cls, emitter: SignalInstance) -> Iterator[None]: ... + @classmethod + def current_emitter(cls) -> SignalInstance | None: ... + @classmethod + def sender(cls) -> Any: ... + +class SignalInstance(Generic[Unpack[Ts]]): + _is_blocked: bool + _is_paused: bool + _debug_hook: ClassVar[Callable[[EmissionInfo], None] | None] + _reemission: Incomplete + _name: Incomplete + _instance: Incomplete + _args_queue: Incomplete + _types: Incomplete + _check_nargs_on_connect: Incomplete + _check_types_on_connect: Incomplete + _slots: Incomplete + _lock: Incomplete + _emit_queue: Incomplete + _recursion_depth: int + _max_recursion_depth: int + _run_emit_loop_inner: Incomplete + _priority_in_use: bool + def __init__( + self, + types: tuple[Unpack[Ts]] | Signature = (), + instance: Any = None, + name: str | None = None, + check_nargs_on_connect: bool = True, + check_types_on_connect: bool = False, + reemission: ReemissionVal = ..., + ) -> None: ... + @staticmethod + def _instance_ref(instance: Any) -> Callable[[], Any]: ... + @property + def signature(self) -> Signature: ... + @property + def instance(self) -> Any: ... + @property + def name(self) -> str: ... + def __repr__(self) -> str: ... + @overload + @overload + def connect( + self: SignalInstance[()], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4, _T5], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4, _T5], RetT]: ... + @overload + def connect( + self: SignalInstance[Unparametrized], + slot: F, + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> F: ... + @overload + def connect( + self: SignalInstance, + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[F], F]: ... + @overload + @overload + def connect( + self: SignalInstance[()], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4, _T5], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4, _T5], RetT]: ... + @overload + def connect( + self: SignalInstance[Unparametrized], + slot: F, + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> F: ... + @overload + def connect( + self: SignalInstance, + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[F], F]: ... + def _append_slot(self, slot: WeakCallback) -> None: ... + def _remove_slot(self, slot: Literal["all"] | int | WeakCallback) -> None: ... + def _try_discard(self, callback: WeakCallback, missing_ok: bool = True) -> None: ... + @overload + def connect( + self: SignalInstance[()], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4, _T5], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4, _T5], RetT]: ... + @overload + def connect( + self: SignalInstance[Unparametrized], + slot: F, + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> F: ... + @overload + def connect( + self: SignalInstance, + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[F], F]: ... + def disconnect_setattr( + self, obj: object, attr: str, missing_ok: bool = True + ) -> None: ... + @overload + def connect( + self: SignalInstance[()], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4], RetT]: ... + @overload + def connect( + self: SignalInstance[type[_T1], type[_T2], type[_T3], type[_T4], type[_T5]], + slot: Callable[[_T1, _T2, _T3, _T4, _T5], RetT], + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[_T1, _T2, _T3, _T4, _T5], RetT]: ... + @overload + def connect( + self: SignalInstance[Unparametrized], + slot: F, + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> F: ... + @overload + def connect( + self: SignalInstance, + *, + thread: threading.Thread | Literal["main", "current"] | None = None, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + on_ref_error: RefErrorChoice = "warn", + priority: int = 0, + ) -> Callable[[F], F]: ... + def disconnect_setitem( + self, obj: object, key: str, missing_ok: bool = True + ) -> None: ... + def _check_nargs( + self, slot: Callable, spec: Signature + ) -> tuple[Signature | None, int | None, bool]: ... + def _raise_connection_error(self, slot: Callable, extra: str = "") -> NoReturn: ... + def _slot_index(self, slot: Callable) -> int: ... + def disconnect( + self, slot: Callable | None = None, missing_ok: bool = True + ) -> None: ... + def __contains__(self, slot: Callable) -> bool: ... + def __len__(self) -> int: ... + def emit( + self, *args: Any, check_nargs: bool = False, check_types: bool = False + ) -> None: ... + def __call__( + self, *args: Any, check_nargs: bool = False, check_types: bool = False + ) -> None: ... + def _run_emit_loop(self, args: tuple[Any, ...]) -> None: ... + def _run_emit_loop_immediate(self) -> None: ... + _args: Incomplete + _caller: Incomplete + def _run_emit_loop_latest_only(self) -> None: ... + def _run_emit_loop_queued(self) -> None: ... + def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: ... + def unblock(self) -> None: ... + def blocked(self) -> ContextManager[None]: ... + def pause(self) -> None: ... + def resume( + self, reducer: ReducerFunc | None = None, initial: Any = ... + ) -> None: ... + def paused( + self, reducer: ReducerFunc | None = None, initial: Any = ... + ) -> ContextManager[None]: ... + def __getstate__(self) -> dict: ... + def __setstate__(self, state: dict) -> None: ... + +class _SignalBlocker: + _signal: Incomplete + _exclude: Incomplete + _was_blocked: Incomplete + def __init__( + self, signal: SignalInstance, exclude: Iterable[str | SignalInstance] = () + ) -> None: ... + def __enter__(self) -> None: ... + def __exit__(self, *args: Any) -> None: ... + +class _SignalPauser: + _was_paused: Incomplete + _signal: Incomplete + _reducer: Incomplete + _initial: Incomplete + def __init__( + self, signal: SignalInstance, reducer: ReducerFunc | None, initial: Any + ) -> None: ... + def __enter__(self) -> None: ... + def __exit__(self, *args: Any) -> None: ... + +_compiled: bool diff --git a/typesafety/test_group.yml b/typesafety/test_group.yml index 51a588b6..6a8bfab1 100644 --- a/typesafety/test_group.yml +++ b/typesafety/test_group.yml @@ -12,7 +12,7 @@ t = T() reveal_type(T.e) # N: Revealed type is "psygnal._group_descriptor.SignalGroupDescriptor" reveal_type(t.e) # N: Revealed type is "psygnal._group.SignalGroup" - reveal_type(t.e.x) # N: Revealed type is "psygnal._signal.SignalInstance[psygnal._signal.GroupSignalInstance]" + reveal_type(t.e.x) # N: Revealed type is "psygnal._signal.SignalInstance[psygnal._signal.Unparametrized]" @t.e['x'].connect def func(x: int) -> None: From 4156847273c068e9638812cd6a0df344e9c86c5e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 2 Apr 2024 20:14:50 -0400 Subject: [PATCH 14/16] skip test --- tests/test_group.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_group.py b/tests/test_group.py index abac651e..037ee95c 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -6,8 +6,6 @@ import pytest -import psygnal - try: from typing import Annotated # py39 except ImportError: @@ -420,7 +418,8 @@ def test_delayed_relay_connect() -> None: gmock.assert_not_called() -@pytest.mark.skipif(psygnal._compiled, reason="requires uncompiled psygnal") +# @pytest.mark.skipif(psygnal._compiled, reason="requires uncompiled psygnal") +@pytest.mark.skip def test_group_relay_signatures() -> None: from inspect import signature From 330cfd1667d193c7c55489c8216c38634e93930d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 2 Apr 2024 20:17:02 -0400 Subject: [PATCH 15/16] Add mypy to pip install command --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38d4be1b..cfaefdf8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: with: python-version: "3.x" - run: | - pip install ruff + pip install ruff mypy CHECK_STUBS=1 python scripts/build_stub.py test: From 66a86ceba2805cff08cfb33a3a68f514de98d034 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 4 Apr 2024 16:53:27 -0400 Subject: [PATCH 16/16] prebuild sig --- src/psygnal/_signal.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 74614909..cc0ceea0 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -550,6 +550,7 @@ def __init__( self._instance: Callable = self._instance_ref(instance) self._args_queue: list[tuple] = [] # filled when paused self._types = types + self._signature = _build_signature(*types) self._check_nargs_on_connect = check_nargs_on_connect self._check_types_on_connect = check_types_on_connect self._slots: list[WeakCallback] = [] @@ -584,7 +585,7 @@ def _instance_ref(instance: Any) -> Callable[[], Any]: @property def signature(self) -> Signature: """Signature supported by this `SignalInstance`.""" - return _build_signature(*self._types) + return self._signature @property def instance(self) -> Any: