Repository: napari/napari

Issue:
sys.excepthook override, and GUI error dialogs
# Description
This PR is just a loose proposal for discussion, and results from thinking about #616 

With more complicated and open-ended features, such as the "advanced open dialog", the likelihood of errors increases (with, for instance, unexpected user input or parameter permutations).  Currently, all errors would be printed as tracebacks to the console.  But with a GUI application, it's often desirable to present a cleaner, more interpretable error message (as an example, I often hear Fiji beginners confused about the cryptic error console).  The idea here is that if there are predictable errors that a user is likely to commit (e.g. typo in filename, choosing a `channel_axis` with a huge size), then we raise a specific error which will be caught and shown to the user with obvious next steps.  This PR introduces two things

1. A start to internal exception classes with a base `napari.exceptions.NapariError` class
2. A modified `sys.excepthook` that shows a subset of exceptions to the user with a `QMessageBox`

Note that the presentation of errors in the gui is _opt-in_, it will currently only show a `QMessageBox` for certain exception classes (such as `NapariError`; first example in gif below))... otherwise it dumps to the console as usual (second example in gif below).  That behavior is modifiable in `_qt.exceptions. ExceptionHandler`.

![Untitled](https://user-images.githubusercontent.com/1609449/70851069-dc4cc800-1e5e-11ea-9802-4eb74b86fb18.gif)

It's also worth noting that this will only currently work when napari is the main process (for instance, by running `napari` on the command line or using `python -m napari` in development).  Ipython traps all exceptions and does not use `sys.excepthook`.  But I think [these docs](https://ipython.readthedocs.io/en/stable/api/generated/IPython.core.interactiveshell.html#IPython.core.interactiveshell.InteractiveShell.excepthook) could help if we wanted it to show in ipython launched things as well.



## Type of change
<!-- Please delete options that are not relevant. -->
- [x] New feature (non-breaking change which adds functionality)

# References
https://docs.python.org/3/library/sys.html#sys.excepthook

# How has this been tested?
<!-- Please describe the tests that you ran to verify your changes. -->
- [ ] example: the test suite for my feature covers cases x, y, and z
- [ ] example: all tests pass with my change

## Final checklist:
- [ ] My PR is the minimum possible work for the desired functionality
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] I have added tests that prove my fix is effective or that my feature works



Discussion:

Resolving patch:
diff --git a/napari/_qt/event_loop.py b/napari/_qt/event_loop.py
--- a/napari/_qt/event_loop.py
+++ b/napari/_qt/event_loop.py
@@ -7,6 +7,8 @@
 from qtpy.QtGui import QPixmap
 from qtpy.QtWidgets import QApplication, QSplashScreen
 
+from .exceptions import ExceptionHandler
+
 
 def _create_application(argv) -> QApplication:
     """Create our QApplication.
@@ -64,6 +66,11 @@ def gui_qt(*, startup_logo=False):
             app._splash_widget = splash_widget
     else:
         app._existed = True
+
+    # instantiate the exception handler
+    exception_handler = ExceptionHandler()
+    sys.excepthook = exception_handler.handle
+
     yield app
     # if the application already existed before this function was called,
     # there's no need to start it again.  By avoiding unnecessary calls to
diff --git a/napari/_qt/exceptions.py b/napari/_qt/exceptions.py
new file mode 100644
--- /dev/null
+++ b/napari/_qt/exceptions.py
@@ -0,0 +1,45 @@
+import logging
+import traceback
+from types import TracebackType
+from typing import Type
+
+from qtpy.QtCore import QObject, Signal
+
+
+class ExceptionHandler(QObject):
+    """General class to handle all uncaught exceptions in the Qt event loop"""
+
+    error = Signal(tuple)
+
+    def handle(
+        self,
+        etype: Type[BaseException],
+        value: BaseException,
+        tb: TracebackType,
+    ):
+        """Our sys.excepthook override.
+
+        This function handles uncaught exceptions and can delegate to a
+        secondary handler, whether it be a GUI dialog, or an IPython traceback
+        printout.  The override to ``sys.excepthook`` happens in
+        :func:`napari.gui_qt`, and therefore this is only active when the qt
+        event loop has been started by napari.
+
+        The three parameters here are what would be returned from
+        :func:`sys.exc_info()`.
+
+        Parameters
+        ----------
+        etype : Type[BaseException]
+            The type of error raised
+        value : BaseException
+            The error instance
+        tb : TracebackType
+            The traceback object associated with the error.
+        """
+        # etype.__module__ contains the module raising the error
+        # Custom exception classes can have different behavior
+        # can add custom exception handlers here ...
+        text = "".join(traceback.format_exception(etype, value, tb))
+        logging.error("Unhandled exception:\n%s", text)
+        self.error.emit((etype, value, tb))
diff --git a/napari/utils/misc.py b/napari/utils/misc.py
--- a/napari/utils/misc.py
+++ b/napari/utils/misc.py
@@ -182,6 +182,9 @@ def __str__(self):
 
 
 camel_to_snake_pattern = re.compile(r'(.)([A-Z][a-z]+)')
+camel_to_spaces_pattern = re.compile(
+    r"((?<=[a-z])[A-Z]|(?<!\A)[A-R,T-Z](?=[a-z]))"
+)
 
 
 def camel_to_snake(name):
@@ -189,6 +192,10 @@ def camel_to_snake(name):
     return camel_to_snake_pattern.sub(r'\1_\2', name).lower()
 
 
+def camel_to_spaces(val):
+    return camel_to_spaces_pattern.sub(r" \1", val)
+
+
 T = TypeVar('T', str, Sequence[str])
 
 


Test patch:
diff --git a/napari/_qt/_tests/test_exception_handler.py b/napari/_qt/_tests/test_exception_handler.py
new file mode 100644
--- /dev/null
+++ b/napari/_qt/_tests/test_exception_handler.py
@@ -0,0 +1,17 @@
+import logging
+from napari._qt.exceptions import ExceptionHandler
+
+
+# caplog fixture comes from pytest
+# https://docs.pytest.org/en/stable/logging.html#caplog-fixture
+def test_exception_handler(qtbot, caplog):
+    """Test exception handler logs to console and emits a signal."""
+    handler = ExceptionHandler()
+    with qtbot.waitSignal(handler.error, timeout=1000):
+        with caplog.at_level(logging.ERROR):
+            handler.handle(ValueError, ValueError("whoops"), None)
+    assert (len(caplog.records)) == 1
+    record = caplog.records[0]
+    assert record.levelname == 'ERROR'
+    assert 'Unhandled exception' in record.message
+    assert 'ValueError: whoops' in record.message