From 938e918d86ebd860b862ce516840860a45dd3f90 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 14 Aug 2023 18:18:34 -0400 Subject: [PATCH 1/4] fix: fix signature inspection on debounced/throttled --- src/psygnal/_throttler.py | 24 +++++++++++++++++++++--- tests/test_throttler.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/psygnal/_throttler.py b/src/psygnal/_throttler.py index b883c5ad..ae302fe1 100644 --- a/src/psygnal/_throttler.py +++ b/src/psygnal/_throttler.py @@ -1,10 +1,13 @@ from __future__ import annotations from threading import Timer -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable from typing_extensions import Literal +if TYPE_CHECKING: + import inspect + Kind = Literal["throttler", "debouncer"] EmissionPolicy = Literal["trailing", "leading"] @@ -18,7 +21,7 @@ def __init__( interval: int = 100, policy: EmissionPolicy = "leading", ) -> None: - self._func = func + self.__wrapped__: Callable[..., Any] = func self._interval: int = interval self._policy: EmissionPolicy = policy self._has_pending: bool = False @@ -27,9 +30,18 @@ def __init__( self._args: tuple[Any, ...] = () self._kwargs: dict[str, Any] = {} + # this mimics what functools.wraps does, but avoids __dict__ usage and other + # things that won't work with mypyc... HOWEVER, most of these dynamic + # assignments won't work in mypyc anyway + self.__module__: str = getattr(func, "__module__", "") + self.__name__: str = getattr(func, "__name__", "") + self.__qualname__: str = getattr(func, "__qualname__", "") + self.__doc__: str | None = getattr(func, "__doc__", None) + self.__annotations__: dict[str, Any] = getattr(func, "__annotations__", {}) + def _actually_call(self) -> None: self._has_pending = False - self._func(*self._args, **self._kwargs) + self.__wrapped__(*self._args, **self._kwargs) self._start_timer() def _call_if_has_pending(self) -> None: @@ -53,6 +65,12 @@ def flush(self) -> None: def __call__(self, *args: Any, **kwargs: Any) -> None: raise NotImplementedError("Subclasses must implement this method.") + @property + def __signature__(self) -> inspect.Signature: + import inspect + + return inspect.signature(self.__wrapped__) + class Throttler(_ThrottlerBase): """Class that prevents calling `func` more than once per `interval`. diff --git a/tests/test_throttler.py b/tests/test_throttler.py index d99e9716..5c7f9db3 100644 --- a/tests/test_throttler.py +++ b/tests/test_throttler.py @@ -1,7 +1,11 @@ import time +from inspect import Parameter, signature +from typing import Callable from unittest.mock import Mock -from psygnal import debounced, throttled +import pytest + +from psygnal import SignalInstance, _compiled, debounced, throttled def test_debounced() -> None: @@ -79,3 +83,30 @@ def test_flush() -> None: f1.flush() time.sleep(0.2) mock1.assert_called_once() + + +@pytest.mark.parametrize("deco", [debounced, throttled]) +def test_throttled_debounced_signature(deco: Callable) -> None: + mock = Mock() + + @deco(timeout=0) + def f1(x: int) -> None: + """Doc.""" + mock(x) + + # make sure we can still inspect the signature + assert signature(f1).parameters["x"] == Parameter( + "x", Parameter.POSITIONAL_OR_KEYWORD, annotation=int + ) + + # make sure these are connectable + sig = SignalInstance((int, int, int)) + sig.connect(f1) + sig.emit(1, 2, 3) + time.sleep(0.1) + mock.assert_called_once_with(1) + + if not _compiled: + # unfortunately, dynamic assignment of __doc__ and stuff isn't possible in mypyc + assert f1.__doc__ == "Doc." + assert f1.__name__ == "f1" From 3a0aaa303bff5bf800f156cda90e8c4238d81ae8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 14 Aug 2023 19:18:58 -0400 Subject: [PATCH 2/4] build: don't compile throttler --- pyproject.toml | 1 + src/psygnal/_throttler.py | 2 +- tests/test_throttler.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8284dac0..09958f40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,7 @@ exclude = [ "src/psygnal/containers", "src/psygnal/qt.py", "src/psygnal/_pyinstaller_util", + "src/psygnal/_throttler.py", ] [tool.cibuildwheel] diff --git a/src/psygnal/_throttler.py b/src/psygnal/_throttler.py index ae302fe1..96172e0b 100644 --- a/src/psygnal/_throttler.py +++ b/src/psygnal/_throttler.py @@ -32,7 +32,7 @@ def __init__( # this mimics what functools.wraps does, but avoids __dict__ usage and other # things that won't work with mypyc... HOWEVER, most of these dynamic - # assignments won't work in mypyc anyway + # assignments won't work in mypyc anyway (they just do nothing.) self.__module__: str = getattr(func, "__module__", "") self.__name__: str = getattr(func, "__name__", "") self.__qualname__: str = getattr(func, "__qualname__", "") diff --git a/tests/test_throttler.py b/tests/test_throttler.py index 5c7f9db3..97a049b4 100644 --- a/tests/test_throttler.py +++ b/tests/test_throttler.py @@ -89,7 +89,7 @@ def test_flush() -> None: def test_throttled_debounced_signature(deco: Callable) -> None: mock = Mock() - @deco(timeout=0) + @deco(timeout=0, leading=True) def f1(x: int) -> None: """Doc.""" mock(x) @@ -103,7 +103,6 @@ def f1(x: int) -> None: sig = SignalInstance((int, int, int)) sig.connect(f1) sig.emit(1, 2, 3) - time.sleep(0.1) mock.assert_called_once_with(1) if not _compiled: From d52bde8c46e6bbfdc99b976f410caa8ec430fb89 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 14 Aug 2023 19:25:00 -0400 Subject: [PATCH 3/4] chore: better typing --- src/psygnal/_throttler.py | 52 ++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/src/psygnal/_throttler.py b/src/psygnal/_throttler.py index 96172e0b..52ebd63c 100644 --- a/src/psygnal/_throttler.py +++ b/src/psygnal/_throttler.py @@ -1,28 +1,30 @@ from __future__ import annotations from threading import Timer -from typing import TYPE_CHECKING, Any, Callable - -from typing_extensions import Literal +from typing import TYPE_CHECKING, Any, Callable, Generic if TYPE_CHECKING: import inspect -Kind = Literal["throttler", "debouncer"] -EmissionPolicy = Literal["trailing", "leading"] + from typing_extensions import Literal, ParamSpec + + Kind = Literal["throttler", "debouncer"] + EmissionPolicy = Literal["trailing", "leading"] + + P = ParamSpec("P") -class _ThrottlerBase: +class _ThrottlerBase(Generic["P"]): _timer: Timer def __init__( self, - func: Callable[..., Any], + func: Callable[P, Any], interval: int = 100, policy: EmissionPolicy = "leading", ) -> None: - self.__wrapped__: Callable[..., Any] = func - self._interval: int = interval + self.__wrapped__: Callable[P, Any] = func + self._interval: float = interval / 1000 self._policy: EmissionPolicy = policy self._has_pending: bool = False self._timer: Timer = Timer(0, lambda: None) @@ -50,7 +52,7 @@ def _call_if_has_pending(self) -> None: def _start_timer(self) -> None: self._timer.cancel() - self._timer = Timer(self._interval / 1000, self._call_if_has_pending) + self._timer = Timer(self._interval, self._call_if_has_pending) self._timer.start() def cancel(self) -> None: @@ -62,7 +64,7 @@ def flush(self) -> None: """Force a call if there is one pending.""" self._call_if_has_pending() - def __call__(self, *args: Any, **kwargs: Any) -> None: + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: raise NotImplementedError("Subclasses must implement this method.") @property @@ -72,12 +74,12 @@ def __signature__(self) -> inspect.Signature: return inspect.signature(self.__wrapped__) -class Throttler(_ThrottlerBase): +class Throttler(_ThrottlerBase, Generic["P"]): """Class that prevents calling `func` more than once per `interval`. Parameters ---------- - func : Callable[..., Any] + func : Callable[P, Any] a function to wrap interval : int, optional the minimum interval in ms that must pass before the function is called again, @@ -91,13 +93,13 @@ class Throttler(_ThrottlerBase): def __init__( self, - func: Callable[..., Any], + func: Callable[P, Any], interval: int = 100, policy: EmissionPolicy = "leading", ) -> None: super().__init__(func, interval, policy) - def __call__(self, *args: Any, **kwargs: Any) -> None: + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: """Call underlying function.""" self._has_pending = True self._args = args @@ -110,12 +112,12 @@ def __call__(self, *args: Any, **kwargs: Any) -> None: self._start_timer() -class Debouncer(_ThrottlerBase): +class Debouncer(_ThrottlerBase, Generic["P"]): """Class that waits at least `interval` before calling `func`. Parameters ---------- - func : Callable[..., Any] + func : Callable[P, Any] a function to wrap interval : int, optional the minimum interval in ms that must pass before the function is called again, @@ -129,13 +131,13 @@ class Debouncer(_ThrottlerBase): def __init__( self, - func: Callable[..., Any], + func: Callable[P, Any], interval: int = 100, policy: EmissionPolicy = "trailing", ) -> None: super().__init__(func, interval, policy) - def __call__(self, *args: Any, **kwargs: Any) -> None: + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: """Call underlying function.""" self._has_pending = True self._args = args @@ -147,10 +149,10 @@ def __call__(self, *args: Any, **kwargs: Any) -> None: def throttled( - func: Callable[..., Any] | None = None, + func: Callable[P, Any] | None = None, timeout: int = 100, leading: bool = True, -) -> Callable[..., None] | Callable[[Callable[..., Any]], Callable[..., None]]: +) -> Throttler[P] | Callable[[Callable[P, Any]], Throttler[P]]: """Create a throttled function that invokes func at most once per timeout. The throttled function comes with a `cancel` method to cancel delayed func @@ -192,7 +194,7 @@ def on_change(val: int) ``` """ - def deco(func: Callable[..., Any]) -> Callable[..., None]: + def deco(func: Callable[P, Any]) -> Throttler[P]: policy: EmissionPolicy = "leading" if leading else "trailing" return Throttler(func, timeout, policy) @@ -200,10 +202,10 @@ def deco(func: Callable[..., Any]) -> Callable[..., None]: def debounced( - func: Callable[..., Any] | None = None, + func: Callable[P, Any] | None = None, timeout: int = 100, leading: bool = False, -) -> Callable[..., None] | Callable[[Callable[..., Any]], Callable[..., None]]: +) -> Debouncer[P] | Callable[[Callable[P, Any]], Debouncer[P]]: """Create a debounced function that delays invoking `func`. `func` will not be invoked until `timeout` ms have elapsed since the last time @@ -248,7 +250,7 @@ def on_change(val: int) ``` """ - def deco(func: Callable[..., Any]) -> Callable[..., None]: + def deco(func: Callable[P, Any]) -> Debouncer[P]: policy: EmissionPolicy = "leading" if leading else "trailing" return Debouncer(func, timeout, policy) From af27dc3ed0cb5d3ca2504a33fbbe40e75ee7f4f2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 14 Aug 2023 19:30:17 -0400 Subject: [PATCH 4/4] fix: runtime --- src/psygnal/_throttler.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/psygnal/_throttler.py b/src/psygnal/_throttler.py index 52ebd63c..6fbac8bf 100644 --- a/src/psygnal/_throttler.py +++ b/src/psygnal/_throttler.py @@ -1,7 +1,7 @@ from __future__ import annotations from threading import Timer -from typing import TYPE_CHECKING, Any, Callable, Generic +from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar if TYPE_CHECKING: import inspect @@ -12,9 +12,13 @@ EmissionPolicy = Literal["trailing", "leading"] P = ParamSpec("P") +else: + # just so that we don't have to depend on a new version of typing_extensions + # at runtime + P = TypeVar("P") -class _ThrottlerBase(Generic["P"]): +class _ThrottlerBase(Generic[P]): _timer: Timer def __init__( @@ -74,7 +78,7 @@ def __signature__(self) -> inspect.Signature: return inspect.signature(self.__wrapped__) -class Throttler(_ThrottlerBase, Generic["P"]): +class Throttler(_ThrottlerBase, Generic[P]): """Class that prevents calling `func` more than once per `interval`. Parameters @@ -112,7 +116,7 @@ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: self._start_timer() -class Debouncer(_ThrottlerBase, Generic["P"]): +class Debouncer(_ThrottlerBase, Generic[P]): """Class that waits at least `interval` before calling `func`. Parameters