ui.py 12 KB

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