Repository: napari/napari

Issue:
CTRL-C should exit napari gracefully
## 🐛 Bug

After #1476 napari just prints `KeyboardInterrupt` when CTRL-C is pressed in the system terminal window that used to launch napari.  Prior to 1476 it exited with a crash, which got the job done but was not great. 

Ideally napari would exit gracefully when you hit CTRL-C in the system terminal window.


## To Reproduce

Steps to reproduce the behavior:

1. From a system terminal (e.g. Terminal program on mac)
2. Run "napari" or a script that uses `napari.gui_qt()`
3. Switch back to the terminal window and type CTRL-C

## Expected behavior

Napari exits gracefully.

## Environment

```
napari: not-installed
Platform: macOS-10.15.3-x86_64-i386-64bit
Python: 3.8.1 (default, Jan 8 2020, 16:15:59) [Clang 4.0.1 (tags/RELEASE_401/final)]
Qt: 5.14.2
PyQt5: 5.14.2
NumPy: 1.18.4
SciPy: 1.4.1
Dask: 2.17.2
VisPy: 0.6.5.dev111+g8387ea1a.d20200424

GL version: 2.1 ATI-3.5.5
MAX_TEXTURE_SIZE: 16384

Plugins:
- napari-plugin-engine: 0.1.6
- svg: 0.1.3
```

## Additional context

This is low priority since you can exit with the Quit command, or from the system terminal hit CTRL-Z and `kill %1` the app if necessary. However it seems like exiting gracefully is the right behavior long term.

I tried adding this to our new `ExceptionHandler` class:
```
        # Interpret CTRL-C as a request to quit.
        if isinstance(value, KeyboardInterrupt):
            QApplication.instance().quit()
            return
```
but while it exits cleanly sometimes, sometimes it bus errors or seg faults.


Discussion:

Resolving patch:
diff --git a/napari/_qt/exceptions.py b/napari/_qt/exceptions.py
--- a/napari/_qt/exceptions.py
+++ b/napari/_qt/exceptions.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import sys
 import traceback
 from types import TracebackType
 from typing import Optional, Type
@@ -62,6 +63,12 @@ def handle(
         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 ...
+        if isinstance(value, KeyboardInterrupt):
+            print("Closed by KeyboardInterrupt", file=sys.stderr)
+            sys.exit(1)
         if self.gui_exceptions:
             self._show_error_dialog(value)
         else:


Test patch:
diff --git a/napari/_qt/_tests/test_exception_handler.py b/napari/_qt/_tests/test_exception_handler.py
--- a/napari/_qt/_tests/test_exception_handler.py
+++ b/napari/_qt/_tests/test_exception_handler.py
@@ -1,4 +1,5 @@
 import logging
+import pytest
 
 from napari._qt.exceptions import ExceptionHandler
 
@@ -18,6 +19,15 @@ def test_exception_handler_interactive(qtbot, caplog):
     assert 'ValueError: whoops' in record.message
 
 
+# caplog fixture comes from pytest
+# https://docs.pytest.org/en/stable/logging.html#caplog-fixture
+def test_keyboard_interupt_handler(qtbot, capsys):
+    handler = ExceptionHandler()
+    with pytest.raises(SystemExit):
+        handler.handle(KeyboardInterrupt, KeyboardInterrupt(), None)
+    assert capsys.readouterr().err == "Closed by KeyboardInterrupt\n"
+
+
 def test_exception_handler_gui(qtbot, make_test_viewer):
     """Test exception handler can create a NapariNotification"""
     viewer = make_test_viewer()