Repository: iterative/dvc

Issue:
Handle KeyboardInterrupt gracefully
Currently, when you send a `SIGINT` to a running instance of DVC, it doesn't terminate properly.

```bash
dvc run 'sleep 999s'
# Ctrl+C
#   or
# pkill -INT dvc
```

Error:
```
Traceback (most recent call last):
  File "/home/mroutis/src/iterative/dvc/.venv/bin/dvc", line 11, in <module>
    load_entry_point('dvc', 'console_scripts', 'dvc')()
  File "/home/mroutis/src/iterative/dvc/dvc/main.py", line 22, in main
    ret = cmd.run_cmd()
  File "/home/mroutis/src/iterative/dvc/dvc/command/base.py", line 41, in run_cmd
    return self.run()
  File "/home/mroutis/src/iterative/dvc/dvc/command/run.py", line 34, in run
    no_exec=self.args.no_exec)
  File "/home/mroutis/src/iterative/dvc/dvc/project.py", line 355, in run
    stage.run()
  File "/home/mroutis/src/iterative/dvc/dvc/stage.py", line 488, in run
    self._run()
  File "/home/mroutis/src/iterative/dvc/dvc/stage.py", line 460, in _run
    p.communicate()
  File "/usr/lib/python3.7/subprocess.py", line 925, in communicate
    self.wait()
  File "/usr/lib/python3.7/subprocess.py", line 984, in wait
    return self._wait(timeout=timeout)
  File "/usr/lib/python3.7/subprocess.py", line 1618, in _wait
    (pid, sts) = self._try_wait(0)
  File "/usr/lib/python3.7/subprocess.py", line 1576, in _try_wait
    (pid, sts) = os.waitpid(self.pid, wait_flags)
KeyboardInterrupt
```




Discussion:
With `--verbose` option DVC still should show the stack trace I guess.

Resolving patch:
diff --git a/dvc/logger.py b/dvc/logger.py
--- a/dvc/logger.py
+++ b/dvc/logger.py
@@ -106,21 +106,26 @@ def colorize(msg, color):
     @staticmethod
     def parse_exc(exc, tb=None):
         str_tb = tb if tb else None
-        str_exc = ': {}'.format(str(exc)) if exc else ""
+        str_exc = str(exc) if exc else ""
+        l_str_exc = []
+
+        if len(str_exc) != 0:
+            l_str_exc.append(str_exc)
 
         if exc and hasattr(exc, 'cause') and exc.cause:
             cause_tb = exc.cause_tb if hasattr(exc, 'cause_tb') else None
-            cause_str_exc, cause_str_tb = Logger.parse_exc(exc.cause, cause_tb)
+            l_cause_str_exc, cause_str_tb = Logger.parse_exc(exc.cause,
+                                                             cause_tb)
 
             str_tb = cause_str_tb
-            str_exc = '{}{}'.format(str_exc, cause_str_exc)
+            l_str_exc += l_cause_str_exc
 
-        return (str_exc, str_tb)
+        return (l_str_exc, str_tb)
 
     @staticmethod
     def _prefix(msg, typ):
         color = Logger.LEVEL_COLOR_MAP.get(typ.lower(), '')
-        return Logger.colorize('{}: '.format(msg), color)
+        return Logger.colorize('{}'.format(msg), color)
 
     @staticmethod
     def error_prefix():
@@ -141,43 +146,42 @@ def _with_progress(func, msg):
             func(msg)
 
     @staticmethod
-    def _error_exc(exc):
-        if exc is None:
-            return
+    def _with_exc(func, prefix, msg, suffix="", exc=None):
+        l_str_exc, str_tb = Logger.parse_exc(exc)
 
-        if Logger.logger().getEffectiveLevel() != logging.DEBUG:
-            return
+        if exc is not None and Logger.is_verbose():
+            str_tb = str_tb if str_tb else traceback.format_exc()
+            Logger._with_progress(Logger.logger().error, str_tb)
 
-        prefix = Logger.error_prefix()
-        str_exc, str_tb = Logger.parse_exc(exc)
-        str_tb = str_tb if str_tb else traceback.format_exc()
-        Logger._with_progress(Logger.logger().error, prefix + str_tb)
+        l_msg = [prefix]
+        if msg is not None and len(msg) != 0:
+            l_msg.append(msg)
+        l_msg += l_str_exc
 
-    @staticmethod
-    def _with_exc(func, msg, suffix="", exc=None):
-        Logger._error_exc(exc)
-        msg = msg + Logger.parse_exc(exc)[0] + suffix
-        Logger._with_progress(func, msg)
+        Logger._with_progress(func, ': '.join(l_msg) + suffix)
 
     @staticmethod
     def error(msg, exc=None):
         chat = "\n\nHaving any troubles? Hit us up at dvc.org/support, " \
                "we are always happy to help!"
         Logger._with_exc(Logger.logger().error,
-                         Logger.error_prefix() + msg,
+                         Logger.error_prefix(),
+                         msg,
                          suffix=chat,
                          exc=exc)
 
     @classmethod
     def warn(cls, msg, exc=None):
         cls._with_exc(cls.logger().warning,
-                      cls.warning_prefix() + msg,
+                      cls.warning_prefix(),
+                      msg,
                       exc=exc)
 
     @classmethod
     def debug(cls, msg, exc=None):
         cls._with_exc(cls.logger().debug,
-                      cls.debug_prefix() + msg,
+                      cls.debug_prefix(),
+                      msg,
                       exc=exc)
 
     @staticmethod
@@ -188,6 +192,10 @@ def info(msg):
     def is_quiet():
         return Logger.logger().level == logging.CRITICAL
 
+    @staticmethod
+    def is_verbose():
+        return Logger.logger().getEffectiveLevel() == logging.DEBUG
+
     @classmethod
     def box(cls, msg, border_color=''):
         if cls.is_quiet():
diff --git a/dvc/main.py b/dvc/main.py
--- a/dvc/main.py
+++ b/dvc/main.py
@@ -20,8 +20,11 @@ def main(argv=None):
         cmd = args.func(args)
 
         ret = cmd.run_cmd()
+    except KeyboardInterrupt as ex:
+        Logger.error("Interrupted by the user", ex)
+        ret = 252
     except NotDvcProjectError as ex:
-        Logger.error(str(ex))
+        Logger.error("", ex)
         ret = 253
     except DvcParserError:
         ret = 254


Test patch:
diff --git a/tests/test_logger.py b/tests/test_logger.py
--- a/tests/test_logger.py
+++ b/tests/test_logger.py
@@ -70,6 +70,54 @@ def test_stdout(self, mock_stdout, mock_stderr):
                                    mock_stdout.getvalue())
 
 
+class TestLoggerException(TestDvc):
+    @patch('sys.stderr', new_callable=StringIO)
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_empty_err_msg(self, mock_stdout, mock_stderr):
+        from dvc.exceptions import DvcException
+
+        Logger.init()
+        exc_msg = 'msg'
+        Logger.error("", exc=DvcException(exc_msg))
+        
+        self.assertEqual('', mock_stdout.getvalue())
+        self.assertEqual('Error: {}\n\nHaving any troubles? '
+                         'Hit us up at dvc.org/support, we '
+                         'are always happy to help!\n'.format(exc_msg),
+                         mock_stderr.getvalue())
+
+    @patch('sys.stderr', new_callable=StringIO)
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_empty_exc_msg(self, mock_stdout, mock_stderr):
+        from dvc.exceptions import DvcException
+
+        Logger.init()
+        err_msg = 'msg'
+        Logger.error(err_msg, exc=DvcException(""))
+        
+        self.assertEqual('', mock_stdout.getvalue())
+        self.assertEqual('Error: {}\n\nHaving any troubles? '
+                         'Hit us up at dvc.org/support, we '
+                         'are always happy to help!\n'.format(err_msg),
+                         mock_stderr.getvalue())
+
+    @patch('sys.stderr', new_callable=StringIO)
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_msg_and_exc(self, mock_stdout, mock_stderr):
+        from dvc.exceptions import DvcException
+
+        Logger.init()
+        err_msg = 'error'
+        exc_msg = 'exception'
+        Logger.error(err_msg, exc=DvcException(exc_msg))
+
+        self.assertEqual('', mock_stdout.getvalue())
+        self.assertEqual('Error: {}: {}\n\nHaving any troubles? '
+                         'Hit us up at dvc.org/support, we are always '
+                         'happy to help!\n'.format(err_msg, exc_msg),
+                         mock_stderr.getvalue())
+
+
 class TestLoggerQuiet(TestDvc):
     def setUp(self):
         super(TestLoggerQuiet, self).setUp()
diff --git a/tests/test_run.py b/tests/test_run.py
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -1,5 +1,7 @@
 import os
+import mock
 import filecmp
+import subprocess
 
 from dvc.main import main
 from dvc.utils import file_md5
@@ -173,3 +175,8 @@ def test_run_args_with_spaces(self):
 
         with open(log, 'r') as fobj:
             self.assertEqual(fobj.read(), arg)
+
+    @mock.patch.object(subprocess, 'Popen', side_effect=KeyboardInterrupt)
+    def test_keyboard_interrupt(self, mock_popen):
+        ret = main(['run', 'mycmd'])
+        self.assertEqual(ret, 252)