From 3778c9f22d4e17a1946986ccb648e9de8d5dd9e0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 4 Nov 2024 09:56:49 -0500 Subject: [PATCH] fast: add `emit_fast` method for 10x faster emission (without safety checks) (#331) * fast: add emit_fast * fix benchmark * add another benchmark * remove bench from asv * test cov --- src/psygnal/_signal.py | 50 ++++++++++++++++++++++++++++++-- tests/test_bench.py | 6 ++++ tests/test_psygnal.py | 65 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 53b881b5..8e04090a 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -1175,6 +1175,52 @@ def emit( self._run_emit_loop(args) + def emit_fast(self, *args: Any) -> None: + """Fast emit without any checks. + + This method can be up to 10x faster than `emit()`, but it lacks most of the + features and safety checks of `emit()`. Use with caution. Specifically: + + - It does not support `check_nargs` or `check_types` + - It does not use any thread safety locks. + - It is not possible to query the emitter with `Signal.current_emitter()` + - It is not possible to query the sender with `Signal.sender()` + - It does not support "queued" or "latest-only" reemission modes for nested + emits. It will always use "immediate" mode, wherein signals emitted by + callbacks are immediately processed in a deeper emission loop. + + It DOES, however, support `paused()` and `blocked()` + + 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.) + """ + if self._is_blocked: + return + + if self._is_paused: + self._args_queue.append(args) + return + + try: + for caller in self._slots: + caller.cb(args) + except RecursionError as e: + raise RecursionError( + f"RecursionError when " + f"emitting signal {self.name!r} with args {args}" + ) from e + except EmitLoopError as e: # pragma: no cover + raise e + except Exception as cb_err: + loop_err = EmitLoopError(exc=cb_err, signal=self).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 + def __call__( self, *args: Any, check_nargs: bool = False, check_types: bool = False ) -> None: @@ -1199,9 +1245,9 @@ def _run_emit_loop(self, args: tuple[Any, ...]) -> None: f"RecursionError when " f"emitting signal {self.name!r} with args {args}" ) from e + except EmitLoopError as e: + raise e except Exception as cb_err: - if isinstance(cb_err, EmitLoopError): - raise cb_err loop_err = EmitLoopError( exc=cb_err, signal=self, diff --git a/tests/test_bench.py b/tests/test_bench.py index 6ab79b66..5a433bee 100644 --- a/tests/test_bench.py +++ b/tests/test_bench.py @@ -121,6 +121,12 @@ def test_emit_time(benchmark: Callable, n_connections: int, callback_type: str) benchmark(emitter.one_int.emit, 1) +def test_emit_fast(benchmark: Callable) -> None: + emitter = Emitter() + emitter.one_int.connect(one_int) + benchmark(emitter.one_int.emit_fast, 1) + + @pytest.mark.benchmark def test_evented_creation() -> None: @evented diff --git a/tests/test_psygnal.py b/tests/test_psygnal.py index 1e868259..7696d594 100644 --- a/tests/test_psygnal.py +++ b/tests/test_psygnal.py @@ -120,6 +120,71 @@ def test_basic_signal(): mock.assert_called_once_with(1) +def test_emit_fast(): + """Test emit_fast method.""" + emitter = Emitter() + mock = MagicMock() + emitter.one_int.connect(mock) + emitter.one_int.emit_fast(1) + mock.assert_called_once_with(1) + mock.reset_mock() + + # calling directly also works + emitter.one_int(1) + mock.assert_called_once_with(1) + mock.reset_mock() + + with emitter.one_int.blocked(): + emitter.one_int.emit_fast(2) + mock.assert_not_called() + + with emitter.one_int.paused(): + emitter.one_int.emit_fast(3) + mock.assert_not_called() + emitter.one_int.emit_fast(4) + mock.assert_has_calls([call(3), call(4)]) + + +def test_emit_fast_errors(): + emitter = Emitter() + err = ValueError() + + @emitter.one_int.connect + def boom(v: int) -> None: + raise err + + import re + + error_re = re.compile( + "signal 'tests.test_psygnal.Emitter.one_int'" f".*{re.escape(__file__)}", + re.DOTALL, + ) + with pytest.raises(EmitLoopError, match=error_re): + emitter.one_int.emit_fast(42) + + +def test_emit_fast_recursion_errors(): + """Test emit_fast method.""" + emitter = Emitter() + emitter.one_int.emit_fast(1) + + @emitter.one_int.connect + def callback() -> None: + emitter.one_int.emit(2) + + with pytest.raises(RecursionError): + emitter.one_int.emit_fast(3) + + emitter.one_int.disconnect(callback) + + @emitter.one_int.connect + def callback() -> None: + emitter.one_int.emit_fast(2) + + with pytest.raises(RecursionError): + emitter.one_int.emit_fast(3) + + def test_decorator(): emitter = Emitter() err = ValueError()