Repository: pypa/pip

Issue:
Interrupting a pip download with CTRL-C does not unhide the cursor
Version: pep 6.0.8

Pressing CTRL-C while pip is downloading a package (and displaying its progress bar) shows an "Operation cancelled by user" message and drops the user back to the shell prompt, but does not unhide the cursor that was hidden while displaying the progress bar.

Glancing at the code, it looks like the `progress` library already offers a `SigIntMixin` helper for handling this, and that pip's progress bar isn't using it. Maybe including this mixin in the appropriate place(s) is all that's needed?



Discussion:

Resolving patch:
diff --git a/pip/utils/ui.py b/pip/utils/ui.py
--- a/pip/utils/ui.py
+++ b/pip/utils/ui.py
@@ -3,6 +3,7 @@
 
 import itertools
 import sys
+from signal import signal, SIGINT, default_int_handler
 
 from pip.compat import WINDOWS
 from pip.utils import format_size
@@ -50,6 +51,61 @@ def _select_progress_class(preferred, fallback):
 _BaseBar = _select_progress_class(IncrementalBar, Bar)
 
 
+class InterruptibleMixin(object):
+    """
+    Helper to ensure that self.finish() gets called on keyboard interrupt.
+
+    This allows downloads to be interrupted without leaving temporary state
+    (like hidden cursors) behind.
+
+    This class is similar to the progress library's existing SigIntMixin
+    helper, but as of version 1.2, that helper has the following problems:
+
+    1. It calls sys.exit().
+    2. It discards the existing SIGINT handler completely.
+    3. It leaves its own handler in place even after an uninterrupted finish,
+       which will have unexpected delayed effects if the user triggers an
+       unrelated keyboard interrupt some time after a progress-displaying
+       download has already completed, for example.
+    """
+
+    def __init__(self, *args, **kwargs):
+        """
+        Save the original SIGINT handler for later.
+        """
+        super(InterruptibleMixin, self).__init__(*args, **kwargs)
+
+        self.original_handler = signal(SIGINT, self.handle_sigint)
+
+        # If signal() returns None, the previous handler was not installed from
+        # Python, and we cannot restore it. This probably should not happen,
+        # but if it does, we must restore something sensible instead, at least.
+        # The least bad option should be Python's default SIGINT handler, which
+        # just raises KeyboardInterrupt.
+        if self.original_handler is None:
+            self.original_handler = default_int_handler
+
+    def finish(self):
+        """
+        Restore the original SIGINT handler after finishing.
+
+        This should happen regardless of whether the progress display finishes
+        normally, or gets interrupted.
+        """
+        super(InterruptibleMixin, self).finish()
+        signal(SIGINT, self.original_handler)
+
+    def handle_sigint(self, signum, frame):
+        """
+        Call self.finish() before delegating to the original SIGINT handler.
+
+        This handler should only be in place while the progress display is
+        active.
+        """
+        self.finish()
+        self.original_handler(signum, frame)
+
+
 class DownloadProgressMixin(object):
 
     def __init__(self, *args, **kwargs):
@@ -109,15 +165,16 @@ def __init__(self, *args, **kwargs):
             self.file.flush = lambda: self.file.wrapped.flush()
 
 
-class DownloadProgressBar(WindowsMixin, DownloadProgressMixin, _BaseBar):
+class DownloadProgressBar(WindowsMixin, InterruptibleMixin,
+                          DownloadProgressMixin, _BaseBar):
 
     file = sys.stdout
     message = "%(percent)d%%"
     suffix = "%(downloaded)s %(download_speed)s %(pretty_eta)s"
 
 
-class DownloadProgressSpinner(WindowsMixin, DownloadProgressMixin,
-                              WritelnMixin, Spinner):
+class DownloadProgressSpinner(WindowsMixin, InterruptibleMixin,
+                              DownloadProgressMixin, WritelnMixin, Spinner):
 
     file = sys.stdout
     suffix = "%(downloaded)s %(download_speed)s"


Test patch: