Repository: napari/napari

Issue:
Warnings / notifications make napari crash when called from a thread_worker
## 🐛 Bug

When using `warnings.warn()` from within a `thread_worker` napari freezes with this on stdout:
```
WARNING: QObject::setParent: Cannot set parent, new parent is in a different thread
```

I assume it freezes because the access to the GUI in main thread causes a crash.

## To Reproduce

Steps to reproduce the behavior:

```
import warnings

import napari
import time
from napari._qt.qthreading import thread_worker
import numpy as np

# create a viewer window
viewer = napari.Viewer()

# https://napari.org/guides/stable/threading.html
@thread_worker
def loop_run():
    while viewer.window.qt_viewer:  # loop until napari closes
        print("Hello world", time.time())
        warnings.warn("Time: " + str(time))
        time.sleep(0.5)

# Start the loop
worker = loop_run()
worker.start()

# Start napari
napari.run()
```

![image](https://user-images.githubusercontent.com/12660498/117564590-1c909400-b0ad-11eb-9732-9b6b82b419b2.png)


This code will run fine if `warnings.warn...` is commented out. 

## Expected behavior

I guess the notification manager could use some yield-mechanism to make the update to the GUI from the main thread.

## Environment

 - Please copy and paste the information at napari info option in help menubar here:
napari: 0.4.8rc1
Platform: Windows-10-10.0.19041-SP0
Python: 3.9.2 | packaged by conda-forge | (default, Feb 21 2021, 04:59:43) [MSC v.1916 64 bit (AMD64)]
Qt: 5.15.2
PyQt5: 5.15.4
NumPy: 1.20.2
SciPy: 1.6.2
Dask: 2021.04.0
VisPy: 0.6.6

OpenGL:
- GL version: 4.6.0 - Build 27.20.100.8681
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 1920x1080, scale 1.0

Plugins:
- clEsperanto: 0.8.0
- console: 0.0.3
- napari-image-quality-analyzer: 0.1.0
- napari-skimage-regionprops: 0.0.0
- napari_webcam: 0.1.17
- scikit-image: 0.4.8rc1
- svg: 0.1.4
 - Any other relevant information:

## Additional context



Discussion:
I think that it is classical problem. 
Qt widgets cannot be created outside the main thread. So trying to show warning created outside the main thread may produce such a problem. 
If I think correctly then there is a need to add a mechanism of piping warning to the main thread (for example using QT signal mechanism because Qt signals connected functions are executed inside the main thread).
This also happens when warnings occur in threads not managed by `thread_worker` e.g. `dask` threads, as @jni  and I discovered today.
Because problem comes generally from using thread. 
EDIT

To call the method from the other thread the `invokeMethod` from `QMetaObject` could be used:
https://github.com/4DNucleome/PartSeg/blob/develop/package/PartSeg/common_backend/except_hook.py

Resolving patch:
diff --git a/examples/dev/gui_notifications_threaded.py b/examples/dev/gui_notifications_threaded.py
new file mode 100644
--- /dev/null
+++ b/examples/dev/gui_notifications_threaded.py
@@ -0,0 +1,29 @@
+import time
+import warnings
+
+import napari
+from napari._qt.widgets.qt_viewer_buttons import QtViewerPushButton
+from napari.qt import thread_worker
+
+
+@thread_worker(start_thread=True)
+def make_warning(*_):
+    time.sleep(0.05)
+    warnings.warn('Warning in another thread')
+
+
+@thread_worker(start_thread=True)
+def make_error(*_):
+    time.sleep(0.05)
+    raise ValueError("Error in another thread")
+
+
+viewer = napari.Viewer()
+layer_buttons = viewer.window.qt_viewer.layerButtons
+err_btn = QtViewerPushButton(None, 'warning', 'new Error', make_error)
+warn_btn = QtViewerPushButton(None, 'warning', 'new Warn', make_warning)
+layer_buttons.layout().insertWidget(3, warn_btn)
+layer_buttons.layout().insertWidget(3, err_btn)
+
+
+napari.run()
diff --git a/napari/_qt/qthreading.py b/napari/_qt/qthreading.py
--- a/napari/_qt/qthreading.py
+++ b/napari/_qt/qthreading.py
@@ -1,11 +1,19 @@
 import inspect
 import time
 import warnings
-from functools import wraps
+from functools import partial, wraps
 from typing import Any, Callable, Dict, Optional, Sequence, Set, Type, Union
 
 import toolz as tz
-from qtpy.QtCore import QObject, QRunnable, QThread, QThreadPool, Signal, Slot
+from qtpy.QtCore import (
+    QObject,
+    QRunnable,
+    QThread,
+    QThreadPool,
+    QTimer,
+    Signal,
+    Slot,
+)
 
 from ..utils.translations import trans
 
@@ -27,6 +35,7 @@ class WorkerBaseSignals(QObject):
     finished = Signal()  # emitted when the work is finished
     returned = Signal(object)  # emitted with return value
     errored = Signal(object)  # emitted with error object on Exception
+    warned = Signal(tuple)  # emitted with showwarning args on warning
 
 
 class WorkerBase(QRunnable):
@@ -57,6 +66,19 @@ def __init__(
         self._running = False
         self.signals = SignalsClass()
 
+        self.signals.errored.connect(self._relay_error)
+        self.signals.warned.connect(self._relay_warning)
+
+    def _relay_error(self, exc):
+        from ..utils.notifications import notification_manager
+
+        notification_manager.receive_error(type(exc), exc, exc.__traceback__)
+
+    def _relay_warning(self, show_warn_args):
+        from ..utils.notifications import notification_manager
+
+        notification_manager.receive_warning(*show_warn_args)
+
     def __getattr__(self, name):
         """Pass through attr requests to signals to simplify connection API.
 
@@ -124,7 +146,10 @@ def run(self):
         self.started.emit()
         self._running = True
         try:
-            result = self.work()
+            with warnings.catch_warnings():
+                warnings.filterwarnings("always")
+                warnings.showwarning = lambda *w: self.warned.emit(w)
+                result = self.work()
             if isinstance(result, Exception):
                 if isinstance(result, RuntimeError):
                     # The Worker object has likely been deleted.
@@ -208,7 +233,8 @@ def start(self):
 
         WorkerBase._worker_set.add(self)
         self.finished.connect(lambda: WorkerBase._worker_set.discard(self))
-        QThreadPool.globalInstance().start(self)
+        start_ = partial(QThreadPool.globalInstance().start, self)
+        QTimer.singleShot(10, start_)
 
 
 class FunctionWorker(WorkerBase):


Test patch:
diff --git a/napari/_qt/_tests/test_threading.py b/napari/_qt/_tests/test_threading.py
--- a/napari/_qt/_tests/test_threading.py
+++ b/napari/_qt/_tests/test_threading.py
@@ -1,10 +1,15 @@
 import inspect
 import time
+from functools import partial
+from operator import eq
 
 import pytest
 
 from napari._qt import qthreading
 
+equals_1 = partial(eq, 1)
+equals_3 = partial(eq, 3)
+
 
 def test_as_generator_function():
     """Test we can convert a regular function to a generator function."""
@@ -24,87 +29,92 @@ def func():
 def test_thread_worker(qtbot):
     """Test basic threadworker on a function"""
 
-    func_val = [0]
-    test_val = [0]
-
+    @qthreading.thread_worker
     def func():
-        func_val[0] = 1
         return 1
 
-    def test(v):
-        test_val[0] = 1
-        assert v == 1
+    wrkr = func()
+    assert isinstance(wrkr, qthreading.FunctionWorker)
 
-    thread_func = qthreading.thread_worker(
-        func, connect={'returned': test}, start_thread=False
-    )
-    worker = thread_func()
-    assert isinstance(worker, qthreading.FunctionWorker)
-    assert func_val[0] == 0
-    with qtbot.waitSignal(worker.finished, timeout=20000):
-        worker.start()
-    assert func_val[0] == 1
-    assert test_val[0] == 1
-    assert worker.is_running is False
+    signals = [wrkr.returned, wrkr.finished]
+    checks = [equals_1, lambda: True]
+    with qtbot.waitSignals(signals, check_params_cbs=checks, order="strict"):
+        wrkr.start()
+    assert wrkr.is_running is False
 
 
 def test_thread_generator_worker(qtbot):
     """Test basic threadworker on a generator"""
 
-    yeld_val = [0]
-    test_val = [0]
-
+    @qthreading.thread_worker
     def func():
         yield 1
         yield 1
         return 3
 
-    def test_return(v):
-        yeld_val[0] = 1
-        assert v == 3
+    wrkr = func()
+    assert isinstance(wrkr, qthreading.GeneratorWorker)
 
-    def test_yield(v):
-        test_val[0] = 1
-        assert v == 1
-
-    thread_func = qthreading.thread_worker(
-        func,
-        connect={'returned': test_return, 'yielded': test_yield},
-        start_thread=False,
-    )
-    worker = thread_func()
-    assert isinstance(worker, qthreading.GeneratorWorker)
-    with qtbot.waitSignal(worker.finished):
-        worker.start()
-    assert test_val[0] == 1
-    assert yeld_val[0] == 1
+    signals = [wrkr.yielded, wrkr.yielded, wrkr.returned, wrkr.finished]
+    checks = [equals_1, equals_1, equals_3, lambda: True]
+    with qtbot.waitSignals(signals, check_params_cbs=checks, order="strict"):
+        wrkr.start()
+    assert wrkr.is_running is False
 
 
-def test_thread_raises(qtbot):
-    """Test exceptions get returned to main thread"""
-
+def test_thread_raises2(qtbot):
     handle_val = [0]
 
-    def func():
-        yield 1
-        yield 1
-        raise ValueError('whoops')
-
     def handle_raise(e):
         handle_val[0] = 1
         assert isinstance(e, ValueError)
         assert str(e) == 'whoops'
 
-    thread_func = qthreading.thread_worker(
-        func, connect={'errored': handle_raise}, start_thread=False
+    @qthreading.thread_worker(
+        connect={'errored': handle_raise}, start_thread=False
     )
-    worker = thread_func()
-    assert isinstance(worker, qthreading.GeneratorWorker)
-    with qtbot.waitSignal(worker.finished):
-        worker.start()
+    def func():
+        yield 1
+        yield 1
+        raise ValueError('whoops')
+
+    wrkr = func()
+    assert isinstance(wrkr, qthreading.GeneratorWorker)
+
+    signals = [wrkr.yielded, wrkr.yielded, wrkr.errored, wrkr.finished]
+    checks = [equals_1, equals_1, None, None]
+    with qtbot.waitSignals(signals, check_params_cbs=checks):
+        wrkr.start()
     assert handle_val[0] == 1
 
 
+def test_thread_warns(qtbot):
+    """Test warnings get returned to main thread"""
+    import warnings
+
+    def check_warning(w):
+        return str(w) == 'hey!'
+
+    @qthreading.thread_worker(
+        connect={'warned': check_warning}, start_thread=False
+    )
+    def func():
+        yield 1
+        warnings.warn('hey!')
+        yield 3
+        warnings.warn('hey!')
+        return 1
+
+    wrkr = func()
+    assert isinstance(wrkr, qthreading.GeneratorWorker)
+
+    signals = [wrkr.yielded, wrkr.warned, wrkr.yielded, wrkr.returned]
+    checks = [equals_1, None, equals_3, equals_1]
+    with qtbot.waitSignals(signals, check_params_cbs=checks):
+        wrkr.start()
+    assert wrkr.is_running is False
+
+
 def test_multiple_connections(qtbot):
     """Test the connect dict accepts a list of functions, and type checks"""
 
diff --git a/napari/utils/_tests/test_notification_manager.py b/napari/utils/_tests/test_notification_manager.py
--- a/napari/utils/_tests/test_notification_manager.py
+++ b/napari/utils/_tests/test_notification_manager.py
@@ -30,6 +30,7 @@ def test_notification_manager_no_gui():
 
     previous_exhook = sys.excepthook
     with notification_manager:
+        notification_manager.records.clear()
         # save all of the events that get emitted
         store: List[Notification] = []
         _append = lambda e: store.append(e)  # lambda needed on py3.7  # noqa