Skip to content

Commit

Permalink
create the worker_connect and worker_disconnect methods
Browse files Browse the repository at this point in the history
  • Loading branch information
jborbely committed Aug 29, 2022
1 parent fa513d3 commit 06644a6
Show file tree
Hide file tree
Showing 2 changed files with 226 additions and 16 deletions.
109 changes: 94 additions & 15 deletions msl/qt/threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,25 @@


class Worker(QtCore.QObject):
"""Process an expensive or blocking operation in a thread that is separate from the main thread.

You can access to the attributes of the :class:`Worker` as though they are attributes of the
:class:`Thread`.
finished = Signal()
error = Signal(BaseException, object) # (exception, traceback)

Example
-------
See :class:`~msl.qt.sleep.SleepWorker` for an example of a :class:`Worker`.
"""
def __init__(self, *args, **kwargs):
"""Process an expensive or blocking operation in a thread that is
separate from the main thread.
finished = Signal()
error = Signal(object, object) # (exception, traceback)
You can access to the attributes of the :class:`Worker` as though
they are attributes of the :class:`Thread`.
The ``*args`` and ``**kwargs`` parameters are passed to the :class:`Worker`
when the :meth:`Thread.start` method is called.
Example
-------
See :class:`~msl.qt.sleep.SleepWorker` for an example of a :class:`Worker`.
"""
super(Worker, self).__init__()

def process(self):
"""The expensive or blocking operation to process.
Expand All @@ -45,15 +52,16 @@ def _process(self):
class Thread(QtCore.QObject):

finished = Signal()
"""This :ref:`Signal` is emitted when the thread finishes (i.e., when :meth:`Worker.process` finishes)."""
"""A :class:`~QtCore.Signal` that is emitted when the thread is finished
(i.e., when :meth:`Worker.process` finishes)."""

def __init__(self, worker):
"""Moves the `worker` to a new :class:`QtCore.QThread`.
"""Moves a :class:`Worker` to a new :class:`QtCore.QThread`.
Parameters
----------
worker
A :class:`Worker` subclass that has **NOT** been instantiated.
A :class:`Worker` subclass that has *not* been instantiated.
Example
-------
Expand All @@ -63,6 +71,7 @@ def __init__(self, worker):
self._thread = None
self._worker = None
self._finished = False
self._signals_slots = []

if not callable(worker):
raise TypeError('The Worker for the Thread must not be instantiated')
Expand Down Expand Up @@ -93,7 +102,7 @@ def error_handler(self, exception, traceback):
prompt.critical(''.join(tb.format_exception(type(exception), exception, traceback)))

def is_finished(self):
"""Whether the thread successfully finished.
"""Whether the thread is finished.
Returns
-------
Expand Down Expand Up @@ -130,6 +139,8 @@ def start(self, *args, **kwargs):
self._thread = QtCore.QThread()
self._worker = self._worker_class(*args, **kwargs)
self._worker.moveToThread(self._thread)
for signal, slot in self._signals_slots:
getattr(self._worker, signal).connect(slot)
self._worker.error.connect(lambda *ignore: self._thread.exit(-1))
self._worker.error.connect(self.error_handler)
self._worker.finished.connect(self._worker_finished)
Expand All @@ -151,7 +162,7 @@ def wait(self, milliseconds=None):
----------
milliseconds : :class:`int`, optional
The number of milliseconds to wait before a timeout occurs.
If :data:`None` then this method will never timeout and the
If :data:`None` then this method will never time out and the
thread must return from its `run` method.
Returns
Expand All @@ -166,8 +177,76 @@ def wait(self, milliseconds=None):
return self._check(self._thread.wait)
return self._check(self._thread.wait, int(milliseconds))

def worker_connect(self, signal, slot):
"""Connect a :class:`~QtCore.Signal` from the :class:`Worker` to a :class:`~QtCore.Slot`.
This method is intended to be called *before* a worker thread starts.
Although, you can still call this method when a worker thread is running,
it is easier (fewer characters to type) to access the attributes of a
:class:`Worker` as though they are attributes of the :class:`Thread`.
Parameters
----------
signal : :class:`~QtCore.Signal` or :class:`str`
The `signal` to connect the `slot` to. If a :class:`str`, then either
the name of a class attribute of the :class:`Worker` or the `name`
parameter that was used in the :class:`~QtCore.Signal` constructor.
slot
A callable function to use as the :class:`~QtCore.Slot`.
"""
signal, slot = Thread._check_signal_slot(signal, slot)
if self.is_running():
getattr(self._worker, signal).connect(slot)
else:
self._signals_slots.append((signal, slot))

def worker_disconnect(self, signal, slot):
"""Disconnect a :class:`~QtCore.Slot` from a :class:`~QtCore.Signal` of the :class:`Worker`.
This method is intended to be called *before* a worker thread is restarted.
Although, you can still call this method when a worker thread is running,
it is easier (fewer characters to type) to access the attributes of a
:class:`Worker` as though they are attributes of the :class:`Thread`.
Parameters
----------
signal : :class:`~QtCore.Signal` or :class:`str`
The `signal` to disconnect the `slot` from. Must be the same
value that was used in :meth:`worker_connect`.
slot
Must be the same callable that was used in :meth:`worker_connect`.
"""
signal, slot = Thread._check_signal_slot(signal, slot)
if self.is_running():
getattr(self._worker, signal).disconnect(slot)
else:
try:
self._signals_slots.remove((signal, slot))
except ValueError:
options = '\n'.join(f'{a!r} {b}' for a, b in self._signals_slots)
if not options:
raise ValueError(
'No Worker signals were connected to slots') from None
raise ValueError(
f'The slot {slot} is not connected to the signal '
f'{signal!r}.\nOptions\n{options}') from None

@staticmethod
def _check_signal_slot(signal, slot):
"""Check the input types and returns the appropriate types."""
if not callable(slot):
raise TypeError('slot must be callable')

if isinstance(signal, str):
return signal, slot

if isinstance(signal, Signal):
return str(signal).split('(')[0], slot

raise TypeError('signal must be a QtCore.Signal or string')

def _check(self, method, *args):
"""Wrap all calls to the QThread in a try..except block to silently
"""Wrap all calls to the QThread in a try-except block to silently
ignore the following error:
RuntimeError: Internal C++ object (*.QtCore.QThread) already deleted.
Expand Down
133 changes: 132 additions & 1 deletion tests/test_threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
QtCore,
Thread,
Worker,
Signal,
Slot,
)


Expand Down Expand Up @@ -139,4 +141,133 @@ def slot2():
app.processEvents()

assert values == [16, 16]
t.quit()
t.stop()


def test_connect_signal_slot():
class MyWorker(Worker):
empty = Signal()
renamed = Signal(name='new_name')
data = Signal(float)
state = Signal(bool, int)

def process(self):
for index in range(10):
if index == 2 or index == 4:
self.empty.emit()
elif index == 3:
self.renamed.emit()
elif index == 5:
self.new_name.emit()
elif index == 7:
self.state.emit(True, 1)
elif index == 8:
self.data.emit(1.2)

@Slot()
def on_empty():
values.append('empty')

@Slot()
def on_renamed_1():
values.append('renamed-1')

@Slot()
def on_renamed_2():
values.append('renamed-2')

@Slot()
def on_renamed_3():
values.append('renamed-3')

@Slot(float)
def on_data(val):
values.append(val)

@Slot(float)
def on_data_plus_10(val):
values.append(val+10)

@Slot(bool, int)
def on_state(*args):
values.append(args)

@Slot()
def on_done():
values.append('done')

values = []

app = application()
t = Thread(MyWorker)

with pytest.raises(TypeError, match='QtCore.Signal or string'):
t.worker_connect(b'empty', on_empty)
with pytest.raises(TypeError, match='must be callable'):
t.worker_connect('empty', None)
with pytest.raises(ValueError, match='No Worker signals were connected to slots'):
t.worker_disconnect('empty', on_empty)
with pytest.raises(ValueError, match='No Worker signals were connected to slots'):
t.worker_disconnect('empty', lambda: on_empty())

t.worker_connect(MyWorker.empty, on_empty)
t.worker_connect('empty', on_empty)
t.worker_connect(MyWorker.renamed, on_renamed_1)
t.worker_connect('renamed', on_renamed_3)
t.worker_connect(MyWorker.renamed, lambda: values.append('renamed-4'))
t.worker_connect('new_name', on_renamed_2)
t.worker_connect(MyWorker.data, on_data)
t.worker_connect('data', on_data_plus_10)
t.worker_connect(MyWorker.state, on_state)
t.worker_connect('state', on_state)
t.finished.connect(on_done)
t.start()

# allow some time for the event loop to emit the finished() signal
for i in range(100):
QtCore.QThread.msleep(10)
app.processEvents()

assert values == [
'empty', 'empty',
'renamed-1', 'renamed-3', 'renamed-4', 'renamed-2',
'empty', 'empty',
'renamed-1', 'renamed-3', 'renamed-4', 'renamed-2',
(True, 1), (True, 1),
1.2, 11.2,
'done'
]

t.stop()

with pytest.raises(ValueError, match="not connected to the signal 'invalid'"):
t.worker_disconnect('invalid', on_data)
with pytest.raises(ValueError, match="not connected to the signal 'empty'"):
t.worker_disconnect('empty', on_data)
with pytest.raises(ValueError, match="not connected to the signal 'empty'"):
t.worker_disconnect('empty', lambda: on_empty())

t.worker_disconnect('renamed', on_renamed_3)
t.worker_disconnect(MyWorker.renamed, on_renamed_2)
t.worker_disconnect('data', on_data)
t.worker_disconnect(MyWorker.empty, on_empty)

values.clear()
t.start()

# allow some time for the event loop to emit the finished() signal
for i in range(100):
QtCore.QThread.msleep(10)
app.processEvents()

assert values == [
'empty',
'renamed-1', 'renamed-4',
'empty',
'renamed-1', 'renamed-4',
(True, 1), (True, 1),
11.2,
'done'
]

t.stop()

0 comments on commit 06644a6

Please sign in to comment.