You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ui.py 14KB

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