# -*- coding: future_fstrings -*-
'''
.. console - Comprehensive utility library for ANSI terminals.
.. © 2018, Mike Miller - Released under the LGPL, version 3+.
Experimental Progress Bar functionality.
A demo is available via command-line::
▶ python3 -m console.progress [-l] [-d] # label and debug modes
TODO:
- Gradients/rainbar
- Additional tests
'''
import logging
import time
from math import floor
from . import fg, bg, fx, sc, term_level as _term_level
from .constants import TermLevel
from .detection import detect_unicode_support, get_size, os_name
from .disabled import empty as _empty
from .utils import len_stripped, notify_progress
DEF_TOTAL = 99
DEF_WIDTH = 32
MIN_WIDTH = 12
TIMEDELTAS = (60, 300) # accuracy thresholds, in seconds, one and five minutes
term_width = _term_width_orig = get_size()[0]
log = logging.getLogger(__name__)
# Theme-ing info:
icons = dict(
# name: first, complete, empty, last, done, err_lo, err_hi, err_lbl
ascii = ('[', '#', '-', ']', '+', '<', '>', 'ERR'),
blocks = (' ', '▮', '▯', ' ', '✓', '⏴', '⏵', '✗'),
# empty white bullet is the wrong size, breaks alignment:
boxes = (' ', '▣', '□', ' ', '✓', '⏴', '⏵', '✗'),
bullets = (' ', '•', '•', ' ', '✓', '⏴', '⏵', '✗'),
dies = (' ', '⚅', '⚀', ' ', '✓', '⏴', '⏵', '✗'),
horns = ('🤘', '⛧', '⛤', '🤘', '✓', '⏴', '⏵', '✗'),
segmented = ('▕', '▉', '▉', '▏', '✓', '⏴', '⏵', '✗ '),
faces = (' ', '☻', '☹', ' ', '✓', '⏴', '⏵', '✗'),
wide_faces = (' ', '😎', '😞', ' ', '✓', '⏴', '⏵', '✗'),
stars = ('(', '★', '☆', ')', '✓', '⏴', '⏵', '✗'),
shaded = ('▕', '▓', '░', '▏', '✓', '⏴', '⏵', '✗'),
triangles = ('▕', '▶', '◁', '▏', '✓', '⏴', '⏵', '✗'),
spaces = ('▕', ' ', ' ', '▏', '✓', '⏴', '⏵', '✗'),
)
# icon/style indexes
# 0 1 2 3 4 5 6 7
_if, _ic, _ie, _il, _id, _iel, _ieh, _ieb = range(8)
# styles
_dim_green = fx.dim + fg.green
_dim_amber = fx.dim + fg.i208
_err_color = fg.lightred
styles = dict(
dumb = (_empty,) * 6, # monochrome
# basic, 16 colors or less
simple = ( # str no longer works, call broke fbterm, so using ''
str, # first,
str, # complete
fx.dim, # empty
str, # last
fx.dim, # done
_err_color, # error
),
ocean = (
_dim_green, # first
fg.green, # complete
fg.blue, # empty
fx.dim + fg.blue, # last
_dim_green, # done
_err_color, # error
),
# eight-bit color or higher
amber = (
_dim_amber, # first
fg.i208, # complete
fg.i172, # empty
fx.dim + fg.i172, # last
fg.i172, # done
_err_color, # error
),
amber_mono = (
fx.dim + fg.i208, # first
fx.reverse + fg.i208, # complete
fg.i172, # empty
fx.dim + fg.i172, # last
fx.dim + fx.reverse + fg.i208, # done
_err_color, # error
),
reds = (
fx.dim + fg.red, # first
fg.lightred, # complete
fg.i236, # empty
fg.i236, # last
fg.red, # done
_err_color, # error
),
greyen = (
_dim_green, # first
fg.green, # complete
fg.i236, # empty
fx.dim + fg.i236, # last
_dim_green, # done
_err_color, # error
),
greyam = (
_dim_amber, # first
fg.i208, # complete
fg.i236, # empty
fx.dim + fg.i236, # last
_dim_amber, # done
_err_color, # error
),
ocean8 = (
fx.dim + fg.i70, # first
fg.i70, # complete
fg.i24, # empty
fx.dim + fg.i24, # last
fx.dim + fg.i70, # done
_err_color, # error
),
greyen_bg = (
_dim_green, # first
bg.lightgreen + fg.black, # complete
bg.lightblack, # empty
fg.lightblack, # last
bg.green, # done
_err_color, # error
),
greyen_bg8 = (
fx.dim + fg.i70, # first
bg.i70 + fg.black, # complete
bg.i236, # empty
fx.dim + fg.i236, # last
bg.i22, # done
_err_color, # error
),
)
themes = dict(
basic_color = dict(icons='ascii', styles='ocean'),
basic = dict(icons='ascii', styles='dumb'),
boxes = dict(icons='boxes', styles='default'),
dies = dict(icons='dies', styles='simple',
partial_chars='⚀⚁⚂⚃⚄⚅', partial_char_extra_style=fg.white),
hd_amber = dict(icons='segmented', styles='greyam'),
hd_green = dict(icons='segmented', styles='greyen'),
heavy_metal = dict(icons='horns', styles='reds'),
shaded = dict(icons='shaded', styles='ocean'),
solid = dict(icons='spaces', styles='greyen_bg'),
warm_shaded = dict(icons='shaded', styles='amber'),
)
# figure defaults, icons and styles
styles['default'] = styles['dumb']
icons['default'] = icons['ascii']
themes['default'] = dict(icons='default', styles='default') # loaded later
_unicode_support = detect_unicode_support()
# U-P-G-R-A-Y-E-D-D, a double-dose of unicode and colors…
if _unicode_support:
icons['default'] = icons['blocks']
if _term_level >= TermLevel.ANSI_BASIC:
styles['default'] = styles['ocean']
if _term_level >= TermLevel.ANSI_EXTENDED:
styles['default'] = styles['ocean8']
themes['solid']['styles'] = 'greyen_bg8' # upgrade to hi color
[docs]class ProgressBar:
''' A stylable bar graph for displaying the current progress of task
completion.
ProgressBar is 0-based, i.e. think 0-99 rather than 1-100
The execution flows like this:
- __init__()
- bar() # __call__() by code to set parameters
- _update_status() # check errors and set progress label
- __str__() # and when printed
- render()
Example::
from time import sleep # demo purposes only
from console.screen import sc
from console.progress import ProgressBar
with sc.hidden_cursor():
items = range(256) # example tasks
bar = ProgressBar(total=len(items)) # set total
# simple loop
for i in items:
print(bar(i), end='', flush=True)
sleep(.02)
print()
# with caption
for i in items:
print(bar(i), f' copying: /path/to/img_{i:>04}.jpg',
end='', flush=True)
sleep(.1)
print()
# or use as a simple tqdm-style iterable wrapper:
for i in ProgressBar(range(100)):
sleep(.1)
Arguments:
clear_left: True True to clear and mv to 0, or int offset
debug: None Enable debug output
done: False True on completion, moderates style
expand: False Set width to full terminal width
iterable: object An object to iterate on.
label_mode: True Enable progress percentage label
oob_error: False Out of bounds error occurred
total: 99 Set the total number of items
unicode_support: bool Detection result, determines default icons
width: 32 Full width of bar, padding, and labels
icons: (,,,) Tuple of chars
styles: (,,,) Tuple of ANSI styles
theme: 'name' String name of combined icon & style set
label_fmt: ('%3.0f%%', '%4.1f%%', '%5.2f%%')
Precision—defaults to no decimal places. After each timedelta,
label precision is increased.
timedeltas:(60, 300) | None Thresholds in seconds,
to increase label precision
'''
debug = None
done = False
expand = False
label_fmt = ('%3.0f%%', '%4.1f%%', '%5.2f%%')
label_fmt_str = '%4s'
label_mode = True
oob_error = False
timedeltas = TIMEDELTAS
total = None
unicode_support = _unicode_support
width = DEF_WIDTH
theme = 'default'
icons = icons[theme]
styles = styles[theme]
_clear_left = True
_cached_str = None
_min_width = MIN_WIDTH
_num_complete_chars = 0
_remainder = 0
_iter_n = 0
def __init__(self, iterable=None, **kwargs):
# configure instance
for key, val in kwargs.items():
if key == 'theme':
self.icons = icons[themes[val]['icons']]
self.styles = styles[themes[val]['styles']]
if val.startswith('basic'):
self.unicode_support = False
elif val == 'solid':
self.label_mode = 'internal'
elif key == 'icons':
self.icons = icons[val]
if val == 'ascii':
self.unicode_support = False
elif key == 'styles':
self.styles = styles[val]
elif key == 'expand' and val:
self.width = term_width
self.expand = val
install_resize_handler()
else:
setattr(self, key, val)
# figure widths
_icons = self.icons
if self.width < self._min_width:
self.width = self._min_width
self.padding = len(_icons[_if]) + len(_icons[_il]) # bookends
self._bwidth = self._set_bar_width()
if self._clear_left is True:
self.clear_left = self._clear_left # render
# configure styles
_styles = self.styles
self._comp_style = _styles[_ic]
self._empt_style = _styles[_ie]
self._err_style = _styles[_iel]
self._first = _styles[_if](_icons[_if])
self._last = _styles[_il](_icons[_il])
self.reset() # start time TODO: move to end
# tqdm-style iterable interface
if iterable and not self.total:
try:
self.total = len(iterable)
except (TypeError, AttributeError):
self.total = None
self.iterable = iterable
self(self._iter_n) # call() with initial value of 0
elif self.total is None:
self.total = DEF_TOTAL
def _set_bar_width(self, width=None):
''' Determine the width of the bar only, without labels. '''
if not width: # determine
if self.expand:
width = term_width
else:
width = self.width
if width < self._min_width:
width = self._min_width
self._bwidth_base = width - self.padding
return self._bwidth_base
def __len__(self):
return self.total
def __iter__(self):
''' tqdm-style iterable interface: https://tqdm.github.io/ '''
for obj in self.iterable:
yield obj
self._iter_n += 1
self(self._iter_n) # call complete
print(self, end='')
print()
def __str__(self):
''' Renders the current state as a string. '''
if self._cached_str:
return self._cached_str
# shall we clear the line to the left?
pieces = [self._clear_left if self._clear_left else '']
if self.label_mode and self.label_mode == 'internal': # solid theme
pieces.append(self._render_with_internal_label())
else:
pieces.append(self._render()) # external
if os_name == 'nt': # Windows taskbar progress
notify_progress(floor(self.ratio * 100))
self._cached_str = rendered = ''.join(pieces)
if self.debug:
pieces.append(
f'⇱ r:{self.ratio:5.3f} ncc:{self._num_complete_chars:2d} '
f'rm:{self._remainder!r} '
f'nec:{self._num_empty_chars:2d} '
f'l:{len_stripped(rendered.lstrip(chr(13)))}' # '\r' aka CR
)
self._cached_str = rendered = ''.join(pieces) # again :-/
return rendered
def __repr__(self):
return repr(self.__str__())
def __call__(self, complete):
''' Sets the value of the bar graph. '''
# convert ints to float from 0…1 per-one-tage
self.ratio = ratio = complete / self.total
if self.expand:
if term_width != _term_width_orig: # unix change
self._set_bar_width()
elif os_name == 'nt': # need to explicitly check
self._set_bar_width(get_size()[0])
self._update_status(ratio)
# find num complete and empty chars
ncc = self._get_ncc(self._bwidth, ratio) # for overriding
if ncc < 0: # restrict from 0 to _bwidth
ncc = self._remainder = 0
if ncc > self._bwidth:
ncc = self._bwidth
self._remainder = 0
self._num_complete_chars = ncc
self._num_empty_chars = self._bwidth - ncc
self._cached_str = None # clear cache
return self
def _get_ncc(self, width, ratio):
''' Get the number of completed whole characters. '''
return round(self._bwidth * ratio)
@property
def clear_left(self):
return self._clear_left
@clear_left.setter
def clear_left(self, value):
''' Converts a given integer to an escape sequence. '''
if value is True:
self._clear_left = '\r' # do not use move_x, if term=dumb
elif type(value) is int:
move_x = sc.move_x
if move_x is _empty: # TERM=dumb
self._clear_left = f'\r{" " * value}'
else: # = f'{clear_line(1)}{sc.move_x(value)}' # not needed
self._clear_left = f'\r{sc.move_x(value)}'
elif value in (False, None):
self._clear_left = value
else:
raise TypeError('clear_left: type %s is not valid.' % type(value))
[docs] def reset(self):
''' Reset the bar, start time only for now. '''
# dynamic label fmt, set to None to disable
self._start = time.time()
def _update_status(self, ratio):
''' Check bounds for errors and update label accordingly. '''
# figure label
label = label_unstyled = ''
label_mode = self.label_mode
label_fmt = self.label_fmt[0]
# change label fmt based on time - when slow, go to higher-res display
if self.timedeltas:
delta = time.time() - self._start
if delta > self.timedeltas[1]:
label_fmt = self.label_fmt[2]
elif delta > self.timedeltas[0]:
label_fmt = self.label_fmt[1]
if 0 <= ratio < 1: # in progress
if label_mode:
label = label_unstyled = label_fmt % (ratio * 100)
if self.oob_error: # now fixed, reset
self._first = self.styles[_if](self.icons[_if])
self._last = self.styles[_il](self.icons[_il])
self._comp_style = self.styles[_ic]
self.oob_error = False
self.done = False
else:
if ratio == 1: # done
self.done = True
self._comp_style = self.styles[_id]
self._last = self.styles[_if](self.icons[_il])
if label_mode:
label = label_unstyled = self.label_fmt_str % self.icons[_id]
if self.oob_error: # now fixed, reset
self._first = self.styles[_if](self.icons[_if])
self.oob_error = False
# error - out of bounds :-/
elif ratio > 1:
self.done = True
self.oob_error = True
self._last = self._err_style(self.icons[_ieh])
if label_mode and not label_mode == 'internal':
label_unstyled = self.label_fmt_str % self.icons[_ieb]
label = self._err_style(label_unstyled)
else: # < 0
self.oob_error = True
self.done = False
self._first = self._err_style(self.icons[_iel])
if label_mode and not label_mode == 'internal':
label_unstyled = self.label_fmt_str % self.icons[_ieb]
label = self._err_style(label_unstyled)
self._lbl = label
# dynamic resizing of the bar, depending on label length:
if label and label_mode != 'internal':
self._bwidth = self._bwidth_base - len(label_unstyled) # or label)
else:
self._bwidth = self._bwidth_base
def _render(self):
''' Standard rendering of bar graph. '''
cm_chars = ( # completed
self._comp_style(self.icons[_ic] * self._num_complete_chars)
)
em_chars = ( # empty
self._empt_style(self.icons[_ie] * self._num_empty_chars)
)
return f'{self._first}{cm_chars}{em_chars}{self._last}{self._lbl}'
def _render_with_internal_label(self):
''' Render with a label inside the bar graph. '''
ncc = self._num_complete_chars
bar = self._lbl.center(self._bwidth)
cm_chars = self._comp_style(bar[:ncc])
em_chars = self._empt_style(bar[ncc:])
return f'{self._first}{cm_chars}{em_chars}{self._last}'
[docs]class HiDefProgressBar(ProgressBar):
''' A ProgressBar with increased, sub-character cell resolution,
approx 8x.
Most useful in constrained environments (a small terminal window)
and/or long-running tasks.
Arguments:
width: 8 or greater
partial_chars - sequence of characters to show progress
'''
icons = icons['segmented']
min_width = 8
partial_chars = ('░', '▏', '▎', '▍', '▌', '▋', '▊', '▉')
partial_chars_len = len(partial_chars)
# matching bg helps partial char look a bit more natural:
partial_char_extra_style = bg.i236
def __init__(self, **kwargs):
super().__init__(**kwargs)
# partial chars may be in theme or passed as kwargs
if 'theme' in kwargs:
partial_chars = themes[kwargs['theme']].get('partial_chars')
if partial_chars:
self.partial_chars = partial_chars
self.partial_chars_len = len(partial_chars) # re-calc
pc_es = themes[kwargs['theme']].get('partial_char_extra_style')
if pc_es:
self.partial_char_extra_style = pc_es
if 'partial_chars' in kwargs:
self.partial_chars_len = len(self.partial_chars) # re-calc
def _get_ncc(self, width, ratio):
''' Get the number of complete chars.
This one figures the _remainder for the partial char as well.
'''
sub_chars = round(width * ratio * self.partial_chars_len)
ncc, self._remainder = divmod(sub_chars, self.partial_chars_len)
return ncc
def _render(self):
''' figure partial character '''
p_char = ''
if not self.done and self._remainder:
p_style = self._comp_style
if self.partial_char_extra_style:
if p_style is str:
p_style = self.partial_char_extra_style
else:
p_style = p_style + self.partial_char_extra_style
p_char = p_style(self.partial_chars[self._remainder])
self._num_empty_chars -= 1
cm_chars = self._comp_style(self.icons[_ic] * self._num_complete_chars)
em_chars = self._empt_style(self.icons[_ie] * self._num_empty_chars)
return f'{self._first}{cm_chars}{p_char}{em_chars}{self._last}{self._lbl}'
[docs]def install_resize_handler():
''' Signal handling code - handles the situation when full-width bars are
created via expand = True, and the virtual terminal width changes.
Note: Unix only
'''
if os_name != 'nt':
import signal
def _window_resize_handler(sig, frame):
global term_width
term_width = get_size()[0]
signal.signal(signal.SIGWINCH, _window_resize_handler)
[docs]def progress(value: float,
clear_left=ProgressBar._clear_left,
expand=ProgressBar.expand,
label_mode=ProgressBar.label_mode,
list_themes=False,
theme=ProgressBar.theme,
total: int=DEF_TOTAL,
width=ProgressBar.width,
debug=bool(ProgressBar.debug),
):
''' Convenience function for building a one-off progress bar,
for scripts and CLI, etc.
Arguments:
value The current value.
clear_left: True True to clear and mv to 0, or int offset
debug: None Enable debug output
expand: False Set width to full terminal width
label_mode: True Enable progress percentage label
total: 99 Set the total number of items
width: 32 Full width of bar, padding, and labels
Note:
The value parameter is 0-based, therefore think 0-99,
rather than 1-100.
If you'd like value to signify a percentage instead,
pass ``--total 100`` or other round number as well.
Run ``python3 -m console.progress -l`` for a demo.
'''
debug = debug or log.isEnabledFor(logging.DEBUG) # -v
try: # Yabba Dabba, DOO!
if list_themes:
return 'themes: ' + ' '.join(themes.keys())
else:
del list_themes
if theme in ('hd_green', 'dies'):
bar = HiDefProgressBar(**locals())
else:
bar = ProgressBar(**locals())
result = bar(value)
return result
except Exception as err:
log.exception(f'{err.__class__.__name__}: {err}')
if __name__ == '__main__':
import sys
from time import sleep
# set defaults
ProgressBar.debug = '-d' in sys.argv
ProgressBar.label_mode = '-l' in sys.argv
ProgressBar._clear_left = False # new class default
bars = [
('basic, expanded:\n',
ProgressBar(theme='basic', expand=True)),
('basic clr:', ProgressBar(theme='basic_color')),
('* default:', ProgressBar()),
('shaded:', ProgressBar(theme='shaded')),
('bullets:', ProgressBar(icons='bullets', styles='ocean8')),
('warm_shaded:', ProgressBar(theme='warm_shaded')),
('faces:', ProgressBar(theme='shaded', icons='faces')),
('wide_faces:', ProgressBar(styles='simple', icons='wide_faces')),
('hvy_metal:', ProgressBar(theme='heavy_metal')),
('segmented:', ProgressBar(icons='segmented')),
('triangles:', ProgressBar(theme='shaded', icons='triangles')),
('solid, expanded:\n',
ProgressBar(theme='solid', expand=True)),
('solid mono:', ProgressBar(theme='solid', styles='amber_mono')),
('hd_green:', HiDefProgressBar(styles='greyen')),
('dies:', HiDefProgressBar(theme='dies', # clear_left=4,
partial_char_extra_style=fg.lightred)),
]
# print each in progress
from console.utils import cls
cls()
with sc.hidden_cursor():
try:
for i in range(100):
print()
for label, bar in bars:
print(f' {label:12}', bar(i), sep='')
sleep(.1)
if i < 99:
cls()
sleep(2)
except KeyboardInterrupt:
pass
# print each with a number of values
print()
for label, bar in bars:
# reset once
if bar.done:
bar.done = False
bar._comp_style = bar.styles[_ic]
bar._last = bar.styles[_il](bar.icons[_il])
print(label)
for complete in (-2, 0, 51, 99, 123):
if bar.expand:
print(bar(complete))
else:
print(' ', bar(complete))
print()