Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fast: add emit_fast method for 10x faster emission (without safety checks) #331

Merged
merged 5 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/psygnal/_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +1211 to +1214
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a big fan of this solution, as it sometimes produces unreadable stack trace. But I understand that it is just a copy from plain emmit and should be fixed in both places?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please suggest a better solution. You've complained about the stack traces in the past, and I tried hard to make what I thought were useful, readable traces. I didn't know you still didn't like them, but it would be good to have some more concrete suggestions

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:
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions tests/test_bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions tests/test_psygnal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading