blob: 4c463dea63016c2fc55783b62033d1c6bed617eb [file] [log] [blame]
# Copyright 2023 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from dataclasses import dataclass
import datetime
import os
import re
import typing
import colorama
_ANSI_REGEX = re.compile(r"\x1b\[[^a-zA-Z]*[a-zA-Z]")
def strip_ansi(input: str) -> str:
"""Strip ANSI escape sequences from the given string.
Args:
input (str): String to strip.
Returns:
str: Input string with ANSI escape sequences removed
>>> strip_ansi("\x1b[1mThis text is highlighted\x1b[0m. This is not")
'This text is highlighted. This is not'
"""
return _ANSI_REGEX.sub("", input)
def ellipsize(input: str, width: int) -> str:
"""Fits the given string into the given width, possibly with ellipses.
Ellipsis uses unicode '…'.
Args:
input (str): String to fit
width (int): Maximum width
Returns:
str: String that is at most |width| characters.
>>> ellipsize('hello', 6)
'hello'
>>> ellipsize('hello world', 6)
'hello…'
>>> a = ellipsize(green_highlight('hello') + ' world', 7)
>>> b = green_highlight('hello') + ' …'
>>> a == b
True
>>> a = ellipsize(green_highlight('hello') + ' world', 6)
>>> b = green_highlight('hello') + '…'
>>> a == b
True
>>> a = ellipsize(green_highlight('hello world'), 4)
>>> b = green_highlight('hel…')
>>> a == b
True
>>> bytes(ellipsize('abc ' + green_highlight('defg'), 6).encode())
b'abc \\x1b[32m\\x1b[1md\\xe2\\x80\\xa6\\x1b[0m'
"""
if not _ANSI_REGEX.search(input):
# No ANSI escapes, simply ensure the string is truncated
# to the correct length, and add ellipsis if the string was
# truncated.
if len(input) > width:
return input[: width - 1] + "…"
else:
return input
else:
# ANSI escapes present, now it gets tricky.
# Iterate over each character in the string, treating any
# escape sequences as a single character. All escape sequences
# are printed verbatim, but individual characters are only
# printed if they will fit in the given width. When we reach
# the end of the allowed width, write a final character and
# keep track of where we were. If another printable character
# is encountered we will replace the previously printed
# character with an ellipsis and not print any more. This
# ensures that if printable characters exactly fit in the
# width, we do not need an ellipsis. If they do not fit,
# then the ellipsis will have the same styling as the last
# printable character.
@dataclass
class Character:
char: str
is_ansi: bool
def each_character(input: str) -> typing.Iterator[Character]:
match: re.Match[str]
cur_index: int = 0
for match in _ANSI_REGEX.finditer(input):
for ch in input[cur_index : match.start()]:
yield Character(ch, False)
yield Character(match.group(), True)
cur_index = match.end()
for ch in input[cur_index:]:
yield Character(ch, False)
total_chars: int = 0
output: str = ""
insert_ellipsis_index: int | None = None
for ch in each_character(input):
if ch.is_ansi:
output += ch.char
elif total_chars == width - 1:
insert_ellipsis_index = len(output)
output += ch.char
total_chars += 1
elif total_chars == width and insert_ellipsis_index is not None:
output = (
output[0:insert_ellipsis_index]
+ "…"
+ output[insert_ellipsis_index + 1 :]
)
insert_ellipsis_index = None
elif total_chars >= width:
pass
else:
output += ch.char
total_chars += 1
return output
def format_duration(seconds: float) -> str:
"""Format a duration of seconds for display.
Args:
seconds (float): Number of seconds to format.
Returns:
str: Seconds formatted as a string.
>>> format_duration(10)
'10.0s'
>>> format_duration(60)
'1:00.0'
>>> format_duration(32.33)
'32.3s'
>>> format_duration(1.2)
'1.2s'
>>> format_duration(134.31)
'2:14.3'
"""
if seconds >= 60:
return f"{int(seconds/60)}:{seconds % 60:04.1f}"
else:
return f"{seconds % 60:.1f}s"
def _split_by_weights(weights: list[int], size: int) -> list[int]:
"""Split the given size into an array of sizes by the given weights.
Weights do not need to sum to a specific number, and the overall size
is distributed proportionally to each element.
The output list will be the same length as the weight list.
Args:
weights (list[int]): How much to weight each element of the array.
size (int): The total length being distributed.
Returns:
list[int]: Array containing amount of |size| assigned to each weight.
>>> _split_by_weights([10, 20, 70], 100)
[10, 20, 70]
>>> _split_by_weights([10, 1, 10, 1, 30], 80)
[15, 1, 15, 1, 46]
>>> _split_by_weights([48, 1, 48], 80)
[39, 1, 39]
"""
total = sum(weights)
output = [int(size * weight / total) for weight in weights]
if sum(output) < size:
# See if we can redistribute some of the remaining size to
# any element that was zeroed out.
remaining = size - sum(output)
zeroes = len([x for x in output if x == 0])
if zeroes:
# Check that we have at least 1 remaining character for each
# zeroed element.
per_zero = remaining // zeroes
if per_zero > 0:
output = [o + 1 if o == 0 else o for o in output]
return output
def _make_progress_bar(proportion: float, width: int) -> str:
"""Make a nicely formatted ASCII progress bar.
Args:
proportion (float): The proportion of the bar to fill.
width (int): The width of the bar.
Raises:
ValueError: Proportion is greater than 1.0
ValueError: Width is less than 3
Returns:
str: Formatted progress bar.
>>> _make_progress_bar(0, 10)
'[ ]'
>>> _make_progress_bar(.01, 10)
'[> ]'
>>> _make_progress_bar(.49, 10)
'[===> ]'
>>> _make_progress_bar(.5, 10)
'[====> ]'
>>> _make_progress_bar(.98, 10)
'[=======>]'
>>> _make_progress_bar(1, 10)
'[========]'
>>> _make_progress_bar(2, 10)
Traceback (most recent call last):
...
ValueError: Proportion must be out of 1.0
>>> _make_progress_bar(-1, 10)
Traceback (most recent call last):
...
ValueError: Proportion must be out of 1.0
>>> _make_progress_bar(1, 2)
Traceback (most recent call last):
...
ValueError: Width must be at least 3
"""
if proportion > 1.0 or proportion < 0:
raise ValueError("Proportion must be out of 1.0")
if width < 3:
raise ValueError("Width must be at least 3")
width -= 2
filled = int(proportion * width)
ret = ""
if filled == width:
ret = "=" * width
elif filled == 0 and proportion == 0:
ret = " " * width
elif filled == 0:
ret = ">" + " " * (width - 1)
else:
ret = "=" * (filled) + ">" + " " * (width - filled - 1)
return f"[{ret}]"
def _pad_to_size(input: str, width: int, left: bool = False) -> str:
"""Ensure that the input takes exactly the given width of characters.
Args:
input (str): A string to pad
width (int): The final string length
left (bool, default False): If true, pad from the left.
Returns:
str: Padded string.
>>> _pad_to_size('hello', 8)
'hello '
>>> _pad_to_size('world', 4)
'wor…'
>>> _pad_to_size('again', 8, left=True)
' again'
>>> _pad_to_size('😀 emoji', 7, left=True)
'😀 emoji'
>>> _pad_to_size('😀 emoji', 6, left=True)
'😀 emo…'
>>> bytes(_pad_to_size(green_highlight('Hello'), 4), 'utf-8')
b'\\x1b[32m\\x1b[1mHel\\xe2\\x80\\xa6\\x1b[0m'
>>> bytes(_pad_to_size(green_highlight(' Hello'), 10), 'utf-8')
b'\\x1b[32m\\x1b[1m Hello\\x1b[0m '
"""
input = ellipsize(input, width)
input_len = len(strip_ansi(input))
if input_len < width:
if not left:
input += " " * (width - input_len)
if left:
input = " " * (width - input_len) + input
return input
def _wrap(style_list: list[typing.Any], string: str, style: bool = True) -> str:
"""Wrap a string in a style, resetting the style after the string is printed.
Args:
style_list (list[typing.Any]): List of colorama styles to wrap with.
string (str): String to wrap.
style (bool, optional): If False, don't actually style. Defaults to True.
Returns:
str: Styled string.
>>> bytes(_wrap([colorama.Fore.GREEN], 'Hello'), 'utf-8')
b'\\x1b[32mHello\\x1b[0m'
>>> bytes(_wrap([colorama.Fore.GREEN], 'Hello', style=False), 'utf-8')
b'Hello'
"""
if style:
return "".join(style_list + [string]) + colorama.Style.RESET_ALL
else:
return string
def highlight(input: str, style: bool = True) -> str:
"""Highlight the input string.
Args:
input (str): String to highlight.
style (bool, optional): If False, don't actually style. Defaults to True.
Returns:
str: Styled string.
"""
return _wrap([colorama.Style.BRIGHT], input, style=style)
def error_highlight(input: str, style: bool = True) -> str:
"""Color the input string red and highlight it.
Args:
input (str): String to highlight.
style (bool, optional): If False, don't actually style. Defaults to True.
Returns:
str: Styled string.
"""
return _wrap([colorama.Style.BRIGHT, colorama.Fore.RED], input, style=style)
def warning(input: str, style: bool = True) -> str:
"""Color the input string yellow.
Args:
input (str): String to style.
style (bool, optional): If False, don't actually style. Defaults to True.
Returns:
str: Styled string.
"""
return _wrap([colorama.Fore.YELLOW], input, style=style)
def green(input: str, style: bool = True) -> str:
"""Color the input string green.
Args:
input (str): String to style.
style (bool, optional): If False, don't actually style. Defaults to True.
Returns:
str: Styled string.
"""
return _wrap([colorama.Fore.GREEN], input, style=style)
def green_highlight(input: str, style: bool = True) -> str:
"""Color the input string green and make it highlighted.
Args:
input (str): String to highlight.
style (bool, optional): If False, don't actually style. Defaults to True.
Returns:
str: Styled string.
"""
return _wrap(
[colorama.Fore.GREEN, colorama.Style.BRIGHT], input, style=style
)
def dim(input: str, style: bool = True) -> str:
"""Dim the input string.
Args:
input (str): String to dim.
style (bool, optional): If False, don't actually style. Defaults to True.
Returns:
str: Styled string.
"""
return _wrap([colorama.Style.DIM], input, style=style)
def duration_progress(
name: str,
duration: datetime.timedelta,
width: typing.Union[int, None] = None,
style: bool = True,
) -> str:
"""Create a pretty duration bar.
This is used for operations that may take an indefinite amount
of time. Instead of showing a progress bar, simply display the
duration of time elapsed.
Args:
name (str): Name to display on the line.
duration (datetime.timedelta): The duration to format and display.
width (typing.Union[int, None], optional): If set, limit
to this width. Default is to use the screen width.
style (bool, optional): Use color only if this is True.
Returns:
str: Pretty formatted line.
>>> duration_progress('Testing', datetime.timedelta(microseconds=3201123), width=70, style=False)
'Testing [3.2s] '
>>> duration_progress('Testing a really really long string that will be truncated', datetime.timedelta(microseconds=3201123), width=70, style=False)
'Testing a really really long string that will be tr… [3.2s] '
"""
width = width or os.get_terminal_size().columns
shape = _split_by_weights([75, 5, 20], width)
label_width, padding_width, duration_width = shape
return (
_pad_to_size(name, label_width)
+ _pad_to_size("", padding_width)
+ dim(
_pad_to_size(
f"[{format_duration(duration.total_seconds())}]",
duration_width,
),
style=style,
)
)
def status_progress(
label: str,
proportion: float,
width: typing.Union[int, None] = None,
style: bool = True,
) -> str:
"""Create a pretty status progress bar.
Args:
label (str): Label for the progress bar.
proportion (float): Proportion of the bar to be filled, out of 1.0.
width (int, optional): Width of the bar to output. Default is to use the current terminal size.
Returns:
str: Pretty printed progress bar.
>>> strip_ansi(status_progress("Downloading foo", .3, 80))
'Downloading foo [===============> ] 30.0%'
>>> strip_ansi(status_progress("Downloading everything ever", .04, 40))
'Download… [> ] 4.…'
>>> strip_ansi(status_progress("Downloading foo", .3, 80))
'Downloading foo [===============> ] 30.0%'
"""
width = width or os.get_terminal_size().columns
shape = _split_by_weights([25, 65, 10], width)
label_width, bar_width, info_width = shape
pct = f"{100.0*proportion:4.1f}%"
return (
highlight(_pad_to_size(label, label_width - 1), style=style)
+ " "
+ _make_progress_bar(proportion, bar_width)
+ _pad_to_size(pct, info_width, left=True)
)
if __name__ == "__main__":
import doctest
doctest.testmod()