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