1from __future__ import absolute_import 2from __future__ import division 3 4import itertools 5import sys 6from signal import signal, SIGINT, default_int_handler 7import time 8import contextlib 9import logging 10 11from pip9.compat import WINDOWS 12from pip9.utils import format_size 13from pip9.utils.logging import get_indentation 14from pip9._vendor import six 15from pip9._vendor.progress.bar import Bar, IncrementalBar 16from pip9._vendor.progress.helpers import (WritelnMixin, 17 HIDE_CURSOR, SHOW_CURSOR) 18from pip9._vendor.progress.spinner import Spinner 19 20try: 21 from pip9._vendor import colorama 22# Lots of different errors can come from this, including SystemError and 23# ImportError. 24except Exception: 25 colorama = None 26 27logger = logging.getLogger(__name__) 28 29 30def _select_progress_class(preferred, fallback): 31 encoding = getattr(preferred.file, "encoding", None) 32 33 # If we don't know what encoding this file is in, then we'll just assume 34 # that it doesn't support unicode and use the ASCII bar. 35 if not encoding: 36 return fallback 37 38 # Collect all of the possible characters we want to use with the preferred 39 # bar. 40 characters = [ 41 getattr(preferred, "empty_fill", six.text_type()), 42 getattr(preferred, "fill", six.text_type()), 43 ] 44 characters += list(getattr(preferred, "phases", [])) 45 46 # Try to decode the characters we're using for the bar using the encoding 47 # of the given file, if this works then we'll assume that we can use the 48 # fancier bar and if not we'll fall back to the plaintext bar. 49 try: 50 six.text_type().join(characters).encode(encoding) 51 except UnicodeEncodeError: 52 return fallback 53 else: 54 return preferred 55 56 57_BaseBar = _select_progress_class(IncrementalBar, Bar) 58 59 60class InterruptibleMixin(object): 61 """ 62 Helper to ensure that self.finish() gets called on keyboard interrupt. 63 64 This allows downloads to be interrupted without leaving temporary state 65 (like hidden cursors) behind. 66 67 This class is similar to the progress library's existing SigIntMixin 68 helper, but as of version 1.2, that helper has the following problems: 69 70 1. It calls sys.exit(). 71 2. It discards the existing SIGINT handler completely. 72 3. It leaves its own handler in place even after an uninterrupted finish, 73 which will have unexpected delayed effects if the user triggers an 74 unrelated keyboard interrupt some time after a progress-displaying 75 download has already completed, for example. 76 """ 77 78 def __init__(self, *args, **kwargs): 79 """ 80 Save the original SIGINT handler for later. 81 """ 82 super(InterruptibleMixin, self).__init__(*args, **kwargs) 83 84 self.original_handler = signal(SIGINT, self.handle_sigint) 85 86 # If signal() returns None, the previous handler was not installed from 87 # Python, and we cannot restore it. This probably should not happen, 88 # but if it does, we must restore something sensible instead, at least. 89 # The least bad option should be Python's default SIGINT handler, which 90 # just raises KeyboardInterrupt. 91 if self.original_handler is None: 92 self.original_handler = default_int_handler 93 94 def finish(self): 95 """ 96 Restore the original SIGINT handler after finishing. 97 98 This should happen regardless of whether the progress display finishes 99 normally, or gets interrupted. 100 """ 101 super(InterruptibleMixin, self).finish() 102 signal(SIGINT, self.original_handler) 103 104 def handle_sigint(self, signum, frame): 105 """ 106 Call self.finish() before delegating to the original SIGINT handler. 107 108 This handler should only be in place while the progress display is 109 active. 110 """ 111 self.finish() 112 self.original_handler(signum, frame) 113 114 115class DownloadProgressMixin(object): 116 117 def __init__(self, *args, **kwargs): 118 super(DownloadProgressMixin, self).__init__(*args, **kwargs) 119 self.message = (" " * (get_indentation() + 2)) + self.message 120 121 @property 122 def downloaded(self): 123 return format_size(self.index) 124 125 @property 126 def download_speed(self): 127 # Avoid zero division errors... 128 if self.avg == 0.0: 129 return "..." 130 return format_size(1 / self.avg) + "/s" 131 132 @property 133 def pretty_eta(self): 134 if self.eta: 135 return "eta %s" % self.eta_td 136 return "" 137 138 def iter(self, it, n=1): 139 for x in it: 140 yield x 141 self.next(n) 142 self.finish() 143 144 145class WindowsMixin(object): 146 147 def __init__(self, *args, **kwargs): 148 # The Windows terminal does not support the hide/show cursor ANSI codes 149 # even with colorama. So we'll ensure that hide_cursor is False on 150 # Windows. 151 # This call neds to go before the super() call, so that hide_cursor 152 # is set in time. The base progress bar class writes the "hide cursor" 153 # code to the terminal in its init, so if we don't set this soon 154 # enough, we get a "hide" with no corresponding "show"... 155 if WINDOWS and self.hide_cursor: 156 self.hide_cursor = False 157 158 super(WindowsMixin, self).__init__(*args, **kwargs) 159 160 # Check if we are running on Windows and we have the colorama module, 161 # if we do then wrap our file with it. 162 if WINDOWS and colorama: 163 self.file = colorama.AnsiToWin32(self.file) 164 # The progress code expects to be able to call self.file.isatty() 165 # but the colorama.AnsiToWin32() object doesn't have that, so we'll 166 # add it. 167 self.file.isatty = lambda: self.file.wrapped.isatty() 168 # The progress code expects to be able to call self.file.flush() 169 # but the colorama.AnsiToWin32() object doesn't have that, so we'll 170 # add it. 171 self.file.flush = lambda: self.file.wrapped.flush() 172 173 174class DownloadProgressBar(WindowsMixin, InterruptibleMixin, 175 DownloadProgressMixin, _BaseBar): 176 177 file = sys.stdout 178 message = "%(percent)d%%" 179 suffix = "%(downloaded)s %(download_speed)s %(pretty_eta)s" 180 181 182class DownloadProgressSpinner(WindowsMixin, InterruptibleMixin, 183 DownloadProgressMixin, WritelnMixin, Spinner): 184 185 file = sys.stdout 186 suffix = "%(downloaded)s %(download_speed)s" 187 188 def next_phase(self): 189 if not hasattr(self, "_phaser"): 190 self._phaser = itertools.cycle(self.phases) 191 return next(self._phaser) 192 193 def update(self): 194 message = self.message % self 195 phase = self.next_phase() 196 suffix = self.suffix % self 197 line = ''.join([ 198 message, 199 " " if message else "", 200 phase, 201 " " if suffix else "", 202 suffix, 203 ]) 204 205 self.writeln(line) 206 207 208################################################################ 209# Generic "something is happening" spinners 210# 211# We don't even try using progress.spinner.Spinner here because it's actually 212# simpler to reimplement from scratch than to coerce their code into doing 213# what we need. 214################################################################ 215 216@contextlib.contextmanager 217def hidden_cursor(file): 218 # The Windows terminal does not support the hide/show cursor ANSI codes, 219 # even via colorama. So don't even try. 220 if WINDOWS: 221 yield 222 # We don't want to clutter the output with control characters if we're 223 # writing to a file, or if the user is running with --quiet. 224 # See https://github.com/pypa/pip/issues/3418 225 elif not file.isatty() or logger.getEffectiveLevel() > logging.INFO: 226 yield 227 else: 228 file.write(HIDE_CURSOR) 229 try: 230 yield 231 finally: 232 file.write(SHOW_CURSOR) 233 234 235class RateLimiter(object): 236 def __init__(self, min_update_interval_seconds): 237 self._min_update_interval_seconds = min_update_interval_seconds 238 self._last_update = 0 239 240 def ready(self): 241 now = time.time() 242 delta = now - self._last_update 243 return delta >= self._min_update_interval_seconds 244 245 def reset(self): 246 self._last_update = time.time() 247 248 249class InteractiveSpinner(object): 250 def __init__(self, message, file=None, spin_chars="-\\|/", 251 # Empirically, 8 updates/second looks nice 252 min_update_interval_seconds=0.125): 253 self._message = message 254 if file is None: 255 file = sys.stdout 256 self._file = file 257 self._rate_limiter = RateLimiter(min_update_interval_seconds) 258 self._finished = False 259 260 self._spin_cycle = itertools.cycle(spin_chars) 261 262 self._file.write(" " * get_indentation() + self._message + " ... ") 263 self._width = 0 264 265 def _write(self, status): 266 assert not self._finished 267 # Erase what we wrote before by backspacing to the beginning, writing 268 # spaces to overwrite the old text, and then backspacing again 269 backup = "\b" * self._width 270 self._file.write(backup + " " * self._width + backup) 271 # Now we have a blank slate to add our status 272 self._file.write(status) 273 self._width = len(status) 274 self._file.flush() 275 self._rate_limiter.reset() 276 277 def spin(self): 278 if self._finished: 279 return 280 if not self._rate_limiter.ready(): 281 return 282 self._write(next(self._spin_cycle)) 283 284 def finish(self, final_status): 285 if self._finished: 286 return 287 self._write(final_status) 288 self._file.write("\n") 289 self._file.flush() 290 self._finished = True 291 292 293# Used for dumb terminals, non-interactive installs (no tty), etc. 294# We still print updates occasionally (once every 60 seconds by default) to 295# act as a keep-alive for systems like Travis-CI that take lack-of-output as 296# an indication that a task has frozen. 297class NonInteractiveSpinner(object): 298 def __init__(self, message, min_update_interval_seconds=60): 299 self._message = message 300 self._finished = False 301 self._rate_limiter = RateLimiter(min_update_interval_seconds) 302 self._update("started") 303 304 def _update(self, status): 305 assert not self._finished 306 self._rate_limiter.reset() 307 logger.info("%s: %s", self._message, status) 308 309 def spin(self): 310 if self._finished: 311 return 312 if not self._rate_limiter.ready(): 313 return 314 self._update("still running...") 315 316 def finish(self, final_status): 317 if self._finished: 318 return 319 self._update("finished with status '%s'" % (final_status,)) 320 self._finished = True 321 322 323@contextlib.contextmanager 324def open_spinner(message): 325 # Interactive spinner goes directly to sys.stdout rather than being routed 326 # through the logging system, but it acts like it has level INFO, 327 # i.e. it's only displayed if we're at level INFO or better. 328 # Non-interactive spinner goes through the logging system, so it is always 329 # in sync with logging configuration. 330 if sys.stdout.isatty() and logger.getEffectiveLevel() <= logging.INFO: 331 spinner = InteractiveSpinner(message) 332 else: 333 spinner = NonInteractiveSpinner(message) 334 try: 335 with hidden_cursor(sys.stdout): 336 yield spinner 337 except KeyboardInterrupt: 338 spinner.finish("canceled") 339 raise 340 except Exception: 341 spinner.finish("error") 342 raise 343 else: 344 spinner.finish("done") 345