File: console.py

package info (click to toggle)
python-asv-runner 0.2.1-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 420 kB
  • sloc: python: 1,631; makefile: 13
file content (442 lines) | stat: -rw-r--r-- 14,300 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# Licensed under a 3-clause BSD style license - see LICENSE.rst

"""
A set of utilities for writing output to the console.
"""

import contextlib
import locale
import logging
import os
import sys
import textwrap
import time

from asv_runner import util

WIN = os.name == "nt"


def isatty(file):
    """
    Determines if a file is a tty.

    #### Parameters
    **file** (`file-like object`)
    : The file-like object to check.

    #### Returns
    **isatty** (`bool`)
    : Returns `True` if the file is a tty, `False` otherwise.

    #### Notes
    Most built-in Python file-like objects have an `isatty` member,
    but some user-defined types may not. In such cases, this function
    assumes those are not ttys.
    """
    return file.isatty() if hasattr(file, "isatty") else False


def _color_text(text, color):
    """
    Returns a string wrapped in ANSI color codes for coloring the text in a terminal.

    #### Parameters
    **text** (`str`)
    : The string to colorize.

    **color** (`str`)
    : An ANSI terminal color name. Must be one of the following:
      'black', 'red', 'green', 'brown', 'blue', 'magenta', 'cyan', 'lightgrey',
      'default', 'darkgrey', 'lightred', 'lightgreen', 'yellow', 'lightblue',
      'lightmagenta', 'lightcyan', 'white', or '' (the empty string).

    #### Returns
    **colored_text** (`str`)
    : The input string, bounded by the appropriate ANSI color codes.

    #### Notes
    This function wraps the input text with ANSI color codes based on the given color.
    It won't actually affect the text until it is printed to the terminal.
    """
    color_mapping = {
        "black": "0;30",
        "red": "0;31",
        "green": "0;32",
        "brown": "0;33",
        "blue": "0;34",
        "magenta": "0;35",
        "cyan": "0;36",
        "lightgrey": "0;37",
        "default": "0;39",
        "darkgrey": "1;30",
        "lightred": "1;31",
        "lightgreen": "1;32",
        "yellow": "1;33",
        "lightblue": "1;34",
        "lightmagenta": "1;35",
        "lightcyan": "1;36",
        "white": "1;37",
    }

    color_code = color_mapping.get(color, "0;39")
    return f"\033[{color_code}m{text}\033[0m"


# A dictionary of Unicode characters that have reasonable representations in ASCII.
# This dictionary contains Unicode characters as keys and their corresponding ASCII
# representations as values. This allows for convenient replacement of these specific
# Unicode characters with ASCII ones to prevent them from being replaced by '?'.
#
# The mapping currently includes:
# - 'μ' maps to 'u'
# - '·' maps to '-'
# - '±' maps to '~'
#
# You can find additional characters that might need an entry using:
# `grep -P  -n '[^\x00-\x7F]' -r *`
# in the `asv` source directory.
_unicode_translations = {ord("μ"): "u", ord("·"): "-", ord("±"): "~"}


def _write_with_fallback(s, fileobj):
    """
    Writes the supplied string to the given file-like object, handling potential
    UnicodeEncodeErrors by falling back to the locale's preferred encoding.

    #### Parameters
    `s` (`str`):
    The Unicode string to be written to the file-like object. Raises a `ValueError`
    if `s` is not a Unicode string.

    `fileobj` (file-like object):
    The file-like object to which the string `s` is to be written. On Python 3,
    this must be a text stream. On Python 2, this must be a `file` byte stream.

    #### Notes
    This function first tries to write the input string `s` to the file object
    `fileobj`. If a `UnicodeError` occurs during this process (indicating that the
    string contains characters not representable in the file's encoding), the function
    falls back to encoding the string in the locale's preferred encoding before writing.

    If the string `s` still cannot be encoded in the locale's preferred encoding, the
    function translates the string to replace problematic Unicode characters with
    ASCII ones using the `_unicode_translations` dictionary, and then encodes and
    writes the resulting string to `fileobj` using the "replace" error handling scheme
    (which replaces any non-encodable characters with a suitable replacement marker).

    After the write operation, the function flushes the file object's output buffer to
    ensure that the written data is actually saved to the file.
    """
    if not isinstance(s, str):
        raise ValueError("Input string is not a Unicode string")

    with contextlib.suppress(UnicodeError):
        fileobj.write(s)
        return
    # Fall back to writing bytes
    enc = locale.getpreferredencoding()
    try:
        b = s.encode(enc)
    except UnicodeError:
        s = s.translate(_unicode_translations)
        b = s.encode(enc, errors="replace")

    fileobj.flush()
    fileobj.buffer.write(b)


def color_print(*args, **kwargs):
    """
    Prints colored and styled text to the terminal using ANSI escape sequences.

    #### Parameters
    *args (`tuple` of `str`):
    The positional arguments should come in pairs (`msg`, `color`), where `msg`
    is the string to display and `color` is the color to display it in. `color`
    is an ANSI terminal color name. Must be one of: black, red, green, brown,
    blue, magenta, cyan, lightgrey, default, darkgrey, lightred, lightgreen,
    yellow, lightblue, lightmagenta, lightcyan, white, or '' (the empty string).

    `file` (writable file-like object, optional):
    Where to write to. Defaults to `sys.stdout`. If `file` is not a tty (as determined
    by calling its `isatty` member, if one exists), no coloring will be included. It's
    passed as a keyword argument.

    `end` (`str`, optional):
    The ending of the message. Defaults to "\n". The `end` will be printed after
    resetting any color or font state. It's passed as a keyword argument.

    #### Notes
    This function allows you to print text in various colors to the console, which can
    be helpful for distinguishing different kinds of output or for drawing attention to
    particular messages.

    It works by applying ANSI escape sequences to the input strings according to the
    specified colors. These escape sequences are interpreted by the terminal emulator
    to apply the specified colors and styles.

    #### Example
    ```{code-block} python
    color_print('This is the color ', 'default', 'GREEN', 'green')
    ```
    """
    file = kwargs.get("file", sys.stdout)
    end = kwargs.get("end", "\n")

    if isatty(file) and not WIN:
        for i in range(0, len(args), 2):
            msg = args[i]
            color = "" if i + 1 == len(args) else args[i + 1]
            if color:
                msg = _color_text(msg, color)
            _write_with_fallback(msg, file)

    else:
        for i in range(0, len(args), 2):
            msg = args[i]
            _write_with_fallback(msg, file)

    _write_with_fallback(end, file)


def get_answer_default(prompt, default, use_defaults=False):
    """
    Prompts the user for input and returns the entered value or a default.

    #### Parameters
    `prompt` (`str`):
    The string that is presented to the user.

    `default` (any):
    The value returned if the user doesn't enter anything and just hits Enter. This
    value is also shown in the prompt to indicate to the user what the default is.

    `use_defaults` (`bool`, optional):
    If True, the function will immediately return the default value without prompting
    the user for input. Defaults to False.

    #### Returns
    The user's input, or the provided default value if the user didn't enter anything.

    #### Notes
    This function enhances the built-in `input` function by allowing a default value
    to be specified, which is returned if the user doesn't enter anything.
    """
    color_print(f"{prompt} [{default}]: ", end="")

    if use_defaults:
        return default

    x = input()
    return default if x.strip() == "" else x


def truncate_left(s, l):
    return f"...{s[-(l - 3):]}" if len(s) > l else s


class Log:
    def __init__(self):
        self._indent = 1
        self._total = 0
        self._count = 0
        self._logger = logging.getLogger()
        self._needs_newline = False
        self._last_dot = time.time()
        self._colorama = False
        if sys.platform in {"win32", "cli"}:
            try:
                import colorama

                colorama.init()
                self._colorama = True
            except Exception as exc:
                print(f"On Windows or cli, colorama is suggested, but got {exc}")

    def _stream_formatter(self, record):
        """
        The formatter for standard output
        """
        if self._needs_newline:
            color_print("")
        parts = record.msg.split("\n", 1)
        first_line = parts[0]
        rest = None if len(parts) == 1 else parts[1]
        indent = self._indent + 1
        continued = getattr(record, "continued", False)

        if self._total:
            progress_msg = f"[{self._count / self._total:6.02%}] "
            if not continued:
                color_print(progress_msg, end="")
            indent += len(progress_msg)

        if not continued:
            color_print("·" * self._indent, end="")
            color_print(" ", end="")
        else:
            color_print(" " * indent, end="")

        if hasattr(record, "color"):
            color = record.color
        elif record.levelno < logging.DEBUG:
            color = "default"
        elif record.levelno < logging.INFO:
            color = "default"
        elif record.levelno < logging.WARN:
            if self._indent == 1:
                color = "green"
            elif self._indent == 2:
                color = "blue"
            else:
                color = "default"
        elif record.levelno < logging.ERROR:
            color = "brown"
        else:
            color = "red"

        color_print(first_line, color, end="")
        if rest is not None:
            color_print("")
            detail = textwrap.dedent(rest)
            spaces = " " * indent
            for line in detail.split("\n"):
                color_print(spaces, end="")
                color_print(line)

        self._needs_newline = True
        sys.stdout.flush()

    @contextlib.contextmanager
    def indent(self):
        """
        A context manager to increase the indentation level.
        """
        self._indent += 1
        yield
        self._indent -= 1

    def dot(self):
        if isatty(sys.stdout):
            if time.time() > self._last_dot + 1.0:
                color_print(".", "darkgrey", end="")
                sys.stdout.flush()
                self._last_dot = time.time()

    def set_nitems(self, n):
        """
        Set the number of remaining items to process.  Each of these
        steps should be incremented through using `step`.

        Can be called multiple times. The progress percentage is ensured
        to be non-decreasing, except if 100% was already reached in which
        case it is restarted from 0%.
        """
        try:
            # Ensure count/total is nondecreasing
            self._total = util.ceildiv(n * self._total, self._total - self._count)
            self._count = self._total - n
        except ZeroDivisionError:
            # Reset counting from start
            self._total = n
            self._count = 0

    def step(self):
        """
        Write that a step has been completed.  A percentage is
        displayed along with it.

        If we are stepping beyond the number of items, stop counting.
        """
        self._count = min(self._total, self._count + 1)

    def enable(self, verbose=False):
        sh = logging.StreamHandler()
        sh.emit = self._stream_formatter
        self._logger.addHandler(sh)
        if verbose:
            self._logger.setLevel(logging.DEBUG)
        else:
            self._logger.setLevel(logging.INFO)

    @contextlib.contextmanager
    def set_level(self, level):
        orig_level = self._logger.level
        if not self.is_debug_enabled():
            self._logger.setLevel(level)
        try:
            yield
        finally:
            self._logger.setLevel(orig_level)

    def is_debug_enabled(self):
        return self._logger.getEffectiveLevel() <= logging.DEBUG

    def _message(
        self, routine, message, reserve_space=False, color=None, continued=False
    ):
        kwargs = {}
        extra = {}
        if color is not None:
            extra["color"] = color
        if continued:
            extra["continued"] = True
        if extra:
            kwargs["extra"] = extra

        if reserve_space:
            max_width = max(16, util.terminal_width - 33)
            message = truncate_left(message, max_width)
            self._prev_message = message

        routine(message, **kwargs)

    def info(self, *args, **kwargs):
        self._message(self._logger.info, *args, **kwargs)

    def warning(self, *args, **kwargs):
        self._message(self._logger.warning, *args, **kwargs)

    def debug(self, *args, **kwargs):
        self._message(self._logger.debug, *args, **kwargs)

    def error(self, *args, **kwargs):
        self._message(self._logger.error, *args, **kwargs)

    def add(self, msg):
        if self._needs_newline:
            _write_with_fallback(msg, sys.stdout)
            sys.stdout.flush()
        else:
            self.info(msg)

    def add_padded(self, msg):
        """
        Final part of two-part info message.
        Should be preceded by a call to info/warn/...(msg, reserve_space=True)
        """
        if self._prev_message is None:
            # No previous part: print as an info message
            self.info(msg)
            return

        padding_length = (
            util.terminal_width - len(self._prev_message) - 14 - 1 - len(msg)
        )
        if WIN:
            padding_length -= 1
        padding = " " * padding_length

        self._prev_message = None
        self.add(f" {padding}{msg}")

    def flush(self):
        """
        Flush any trailing newlines. Needs to be called before printing
        to stdout via other means, after using Log.
        """
        if self._needs_newline:
            color_print("")
            self._needs_newline = False
        sys.stdout.flush()