Skip to content
Open
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
15 changes: 15 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
groups:
python-dependencies:
patterns:
- "*"
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ cover-erase=1
cover-html-dir=build

[tool:pytest]
addopts = --pep8 --cov signalslot --cov-report html --doctest-modules
addopts = --cov signalslot --cov-report html --doctest-modules
python_files = signalslot/*.py
norecursedirs = .git docs .tox
looponfailroots = signalslot
Expand Down
102 changes: 102 additions & 0 deletions signalslot/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,108 @@ def emit(self, **kwargs):
if result is not None:
return result

async def async_emit(self, **kwargs):
"""
Async emitter for asynchronous callbacks.
Emit this signal which will execute every connected callback ``slot``,
passing keyword arguments.

If slot is awaitable object, then it will await it and return the result

If a slot returns anything other than None, then :py:meth:`emit` will
return that value preventing any other slot from being called.
"""
for slot in self.slots:
result = slot(**kwargs)
if inspect.isawaitable(result):
result = await result

if result is not None:
return result

def slot(self, slot):
"""
Decorator to connect a callback ``slot`` to this signal.

Args:
slot: callable object

Returns:
connected slot

Examples:
>>> some_signal = Signal()
...
>>> @some_signal.slot
... def some_slot(**kwargs):
... return 'called something'
...
>>> some_signal.emit()
"""
self.connect(slot)
return slot

def once(self, slot, timeout=None, on_timeout_reach=None):
"""
Wraparounds a callback ``slot`` to ensure it will be called just once,
avoiding multiple calls on signal emit
Connects wrapped ``slot`` to this signal and disconnects it when slot called once
Optionally set a ``timeout`` to auto disconnect slot when timeout is exceeded
Optionally set an ``on_timeout_reach`` callback
that will be called if timeout is exceeded before slot called once

Args:
slot: callable object
timeout: timeout in seconds
on_timeout_reach: callback function calling when timeout is exceeded

Returns:
threading.Timer object if timeout is not None

Raises:
TypeError: if slot is not callable
ValueError: if timeout is negative

Examples:
>>> some_signal = Signal()
...
>>> def some_slot(**kwargs):
... return 'called something'
...
>>> def on_timeout():
... raise TimeoutError
...
>>> timer = some_signal.once(some_slot, 5, on_timeout)
...
>>> some_signal.emit()
"""
timer = None

def wrapper(**kwargs):
if timer is not None:
timer.cancel()
self.disconnect(wrapper)
return slot(**kwargs)

if on_timeout_reach is not None and not callable(on_timeout_reach):
raise TypeError('Callback `on_timeout_error` must be callable')

if timeout is not None:
if timeout < 0:
raise ValueError(
"Slot wait timeout must be non-negative")

def on_timeout():
self.disconnect(wrapper)
on_timeout_reach()

timer = threading.Timer(timeout, on_timeout)
timer.daemon = True
timer.start()

self.connect(wrapper)
return timer

def __eq__(self, other):
"""
Return True if other has the same slots connected.
Expand Down
76 changes: 76 additions & 0 deletions signalslot/tests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import asyncio
import time

import pytest
import mock

Expand Down Expand Up @@ -67,6 +70,22 @@ def test_reconnect_does_not_duplicate(self, inspect):
def test_disconnect_does_not_fail_on_not_connected_slot(self, inspect):
self.signal_a.disconnect(self.slot_b)

@pytest.mark.asyncio
async def test_emit_async_slot(self, inspect):
mock_slot = mock.Mock()
async def async_slot(arg, **kwargs):
mock_slot(arg, **kwargs)

self.signal_a.connect(async_slot)

emits = [self.signal_a.async_emit(arg=i) for i in range(3)]
await asyncio.gather(*emits)
mock_slot.assert_has_calls([
mock.call(0),
mock.call(1),
mock.call(2)
], any_order=True)


def test_anonymous_signal_has_nice_repr():
signal = Signal()
Expand Down Expand Up @@ -95,6 +114,63 @@ def cb():
with pytest.raises(SlotMustAcceptKeywords):
self.signal.connect(cb)

def test_connect_via_decorator(self):
mock_slot = mock.Mock()

@self.signal.slot
def cb(**kwargs):
mock_slot(**kwargs)

self.signal.emit(arg='decorated')
mock_slot.assert_called_once_with(arg='decorated')


class TestSignalOnce(object):
def setup_method(self, method):
self.signal = Signal(args=['arg'])
self.mock_slot = mock.Mock()
self.mock_error = mock.Mock()

def test_call_once(self):
self.signal.once(self.mock_slot)
self.signal.emit(arg='once')
self.signal.emit(arg='twice')

self.mock_slot.assert_called_once()

def test_call_once_with_timeout_intime(self):

self.signal.once(self.mock_slot, 0.2, self.mock_error)
time.sleep(0.1)
self.signal.emit(arg='intime')

self.mock_slot.assert_called_once_with(arg='intime')
self.mock_error.assert_not_called()

def test_call_once_with_timeout_overdue(self):

self.signal.once(self.mock_slot, 0.1, self.mock_error)
time.sleep(0.2)
self.signal.emit(arg='overdue')

self.mock_slot.assert_not_called()
self.mock_error.assert_called_once()

@pytest.mark.asyncio
async def test_emit_async_once(self):

async def async_slot(arg, **kwargs):
self.mock_slot(arg, **kwargs)

self.signal.once(async_slot, 0.1, self.mock_error)
self.signal.once(async_slot, None, self.mock_error)
emits = [self.signal.async_emit(arg=i) for i in range(3)]
await asyncio.sleep(0.2)
await asyncio.gather(*emits)

self.mock_slot.assert_called_once()
self.mock_error.assert_called_once()


class MyTestError(Exception):
pass
Expand Down
1 change: 1 addition & 0 deletions test_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ pytest-cov
pytest-pep8
pytest-sugar
pytest-xdist
pytest-asyncio
coverage