Add progressbars.py.
I've been tweaking this file for weeks and I just want to publish it now even though the code is still kind of ugly. I think the interface is done.
This commit is contained in:
parent
ecd663b7e9
commit
e34f3e6c32
1 changed files with 316 additions and 0 deletions
316
voussoirkit/progressbars.py
Normal file
316
voussoirkit/progressbars.py
Normal file
|
@ -0,0 +1,316 @@
|
||||||
|
import shutil
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from voussoirkit import bytestring
|
||||||
|
from voussoirkit import pipeable
|
||||||
|
from voussoirkit import ratelimiter
|
||||||
|
from voussoirkit import sentinel
|
||||||
|
from voussoirkit import stringtools
|
||||||
|
from voussoirkit import vlogging
|
||||||
|
|
||||||
|
log = vlogging.get_logger(__name__, 'progressbars')
|
||||||
|
|
||||||
|
# Base class #######################################################################################
|
||||||
|
|
||||||
|
DONE = sentinel.Sentinel('done')
|
||||||
|
|
||||||
|
class Progress:
|
||||||
|
def __init__(self, total=None, *, topic=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def done(self) -> None:
|
||||||
|
'''
|
||||||
|
Shortcut method for step(value=DONE).
|
||||||
|
|
||||||
|
Should bypass any rendering ratelimits that might be in place, to ensure
|
||||||
|
that the progress bar's final state on screen is the done state.
|
||||||
|
|
||||||
|
Should be idempotent with additional calls to done().
|
||||||
|
|
||||||
|
Should not cause duplicate rendering in the case that step(value>=total)
|
||||||
|
was called before done() was called.
|
||||||
|
'''
|
||||||
|
self.step(DONE)
|
||||||
|
|
||||||
|
def set_topic(self, topic: typing.Union[str, None]) -> None:
|
||||||
|
'''
|
||||||
|
The topic string might be the name of a file being copied / downloaded,
|
||||||
|
the title of the function being run, or any other description of what
|
||||||
|
the progress bar represents.
|
||||||
|
|
||||||
|
topic must not be any other type. Defensive implementations should
|
||||||
|
prepare to receive any other type and e.g. treat them as None.
|
||||||
|
|
||||||
|
topic should not be used for "reticulating splines" text spinners. That
|
||||||
|
should be implemented as a class which shows text messages on each step.
|
||||||
|
|
||||||
|
The implementation might show the topic directly next to the progress
|
||||||
|
bar or somewhere else entirely. It might not show the topic at all.
|
||||||
|
'''
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def set_total(self, total: typing.Union[int, float, None]) -> None:
|
||||||
|
'''
|
||||||
|
All implementations must be prepared to handle int, float, and None.
|
||||||
|
Implementations might switch from determinate modes to indeterminate
|
||||||
|
modes and vice versa.
|
||||||
|
|
||||||
|
total must be greater than 0. Defensive implementations should prepare
|
||||||
|
to receive nonpositive totals.
|
||||||
|
|
||||||
|
total must not be any other type. Defensive implementations should
|
||||||
|
prepare to receive other types and e.g. treat them as indeterminate.
|
||||||
|
'''
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def step(self, value: typing.Union[int, float]) -> None:
|
||||||
|
'''
|
||||||
|
Increment the state of the progressbar to this new value.
|
||||||
|
|
||||||
|
Some implementations may not use the value in their rendering
|
||||||
|
whatsoever, e.g. spinners, but if total is not None then value should
|
||||||
|
try to be relevant.
|
||||||
|
|
||||||
|
Most implementations will benefit from a ratelimiter that only shows a
|
||||||
|
certain number of status updates per second, since very rapid updates
|
||||||
|
can be expensive with diminishing usefulness. However, if the value DONE
|
||||||
|
is given, that should probably bypass the ratelimiter.
|
||||||
|
|
||||||
|
value must not be any other type. Defensive implementations should
|
||||||
|
prepare to receive other types and e.g. re-render the previously used
|
||||||
|
value or ignore them.
|
||||||
|
|
||||||
|
value must be greater than or equal to 0. Defensive implementations
|
||||||
|
should prepare to receive negative values and e.g. clamp to 0.
|
||||||
|
|
||||||
|
In general, value should be less than or equal to total. Defensive
|
||||||
|
implementations should prepare to receive higher values and e.g. clamp
|
||||||
|
to total or continue counting beyond 100%.
|
||||||
|
'''
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# Implementations ##################################################################################
|
||||||
|
|
||||||
|
WIDTH_AUTO = sentinel.Sentinel('width auto')
|
||||||
|
DEFAULT_RATELIMIT = 8
|
||||||
|
DEFAULT_TOTAL_TOSTRING = lambda total: '?' if total is None else str(total)
|
||||||
|
DEFAULT_VALUE_TOSTRING = lambda value, total=0, total_string='': str(value).rjust(len(total_string))
|
||||||
|
|
||||||
|
class Bar1(Progress):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
total=None,
|
||||||
|
*,
|
||||||
|
ratelimit=DEFAULT_RATELIMIT,
|
||||||
|
show_topic=True,
|
||||||
|
topic=None,
|
||||||
|
total_tostring=None,
|
||||||
|
value_tostring=None,
|
||||||
|
width=WIDTH_AUTO,
|
||||||
|
):
|
||||||
|
if not should_stderr():
|
||||||
|
self.step = do_nothing
|
||||||
|
|
||||||
|
self.total = None
|
||||||
|
self._last_value = 0
|
||||||
|
|
||||||
|
self.solid_char = '#'
|
||||||
|
self.blank_char = '.'
|
||||||
|
|
||||||
|
self.ratelimiter = normalize_ratelimiter(ratelimit)
|
||||||
|
|
||||||
|
if width is WIDTH_AUTO:
|
||||||
|
self.width = shutil.get_terminal_size().columns - 2
|
||||||
|
self.width = min(80, self.width)
|
||||||
|
else:
|
||||||
|
self.width = width
|
||||||
|
|
||||||
|
self.total_tostring = total_tostring or DEFAULT_TOTAL_TOSTRING
|
||||||
|
self.value_tostring = value_tostring or DEFAULT_VALUE_TOSTRING
|
||||||
|
|
||||||
|
self.show_topic = show_topic
|
||||||
|
self._set_topic(topic)
|
||||||
|
self.set_total(total)
|
||||||
|
|
||||||
|
def _set_topic(self, topic):
|
||||||
|
if not self.show_topic:
|
||||||
|
topic = None
|
||||||
|
|
||||||
|
if isinstance(topic, str):
|
||||||
|
self.topic = topic
|
||||||
|
self.topic_render = topic + ' '
|
||||||
|
else:
|
||||||
|
self.topic = ''
|
||||||
|
self.topic_render = ''
|
||||||
|
|
||||||
|
def set_topic(self, topic):
|
||||||
|
self._set_topic(topic)
|
||||||
|
self._set_total(self.total)
|
||||||
|
|
||||||
|
def _set_total(self, total):
|
||||||
|
if total is not None and not isinstance(total, (int, float)):
|
||||||
|
log.warning(f'Bar1.set_total does not understand {total}, falling back to None.')
|
||||||
|
total = None
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
self._ind_animation_index = 0
|
||||||
|
|
||||||
|
self.total = total
|
||||||
|
|
||||||
|
self.total_string = self.total_tostring(total)
|
||||||
|
self.total_string_width = stringtools.unicode_width(self.total_string)
|
||||||
|
|
||||||
|
if self.total is None:
|
||||||
|
value_example = 0
|
||||||
|
else:
|
||||||
|
value_example = total
|
||||||
|
value_example = self.value_tostring(value_example)
|
||||||
|
value_width = max(stringtools.unicode_width(value_example), self.total_string_width)
|
||||||
|
|
||||||
|
self.bar_width = self.width
|
||||||
|
self.bar_width -= self.total_string_width
|
||||||
|
self.bar_width -= value_width
|
||||||
|
if self.topic:
|
||||||
|
self.bar_width -= (stringtools.unicode_width(self.topic))
|
||||||
|
# Space between topic and bar.
|
||||||
|
self.bar_width -= 1
|
||||||
|
# Spaces on either side of the bar.
|
||||||
|
self.bar_width -= 2
|
||||||
|
self.bar_width = max(self.bar_width, 1)
|
||||||
|
|
||||||
|
if self.total is not None:
|
||||||
|
self.value_per_block = self.total / self.bar_width
|
||||||
|
|
||||||
|
def set_total(self, total):
|
||||||
|
self._set_total(total)
|
||||||
|
self._done = False
|
||||||
|
|
||||||
|
def _render_line(self, value):
|
||||||
|
value_string = self.value_tostring(value, self.total, self.total_string)
|
||||||
|
|
||||||
|
if self._done:
|
||||||
|
bar = self.solid_char * self.bar_width
|
||||||
|
elif self.total is None:
|
||||||
|
bar = (self.blank_char * self._ind_animation_index) + self.solid_char
|
||||||
|
bar = bar.ljust(self.bar_width, self.blank_char)
|
||||||
|
self._ind_animation_index = (self._ind_animation_index + 1) % self.bar_width
|
||||||
|
else:
|
||||||
|
solid_count = round(value / self.value_per_block)
|
||||||
|
bar = (self.solid_char * solid_count).ljust(self.bar_width, self.blank_char)
|
||||||
|
|
||||||
|
return f'{self.topic_render}{value_string} {bar} {self.total_string}'
|
||||||
|
|
||||||
|
def step(self, value):
|
||||||
|
if value is not DONE and not isinstance(value, (int, float)):
|
||||||
|
log.warning('ProgressBar.step received a non-numeric argument %s.', value)
|
||||||
|
return
|
||||||
|
|
||||||
|
if value is DONE:
|
||||||
|
if self._done:
|
||||||
|
return
|
||||||
|
if self.total is not None:
|
||||||
|
value = self.total
|
||||||
|
else:
|
||||||
|
value = self._last_value
|
||||||
|
self._done = True
|
||||||
|
elif self.total is not None and value >= self.total:
|
||||||
|
self._done = True
|
||||||
|
else:
|
||||||
|
self._done = False
|
||||||
|
if self.ratelimiter and self.ratelimiter.limit(1) is False:
|
||||||
|
return
|
||||||
|
|
||||||
|
line = self._render_line(value)
|
||||||
|
end = '\n' if self._done else '\r'
|
||||||
|
pipeable.stderr(line, end=end)
|
||||||
|
self._last_value = value
|
||||||
|
|
||||||
|
class DoNothing(Progress):
|
||||||
|
'''
|
||||||
|
You can use this when you don't want to use a real progress bar class, but
|
||||||
|
you don't want to use a None and preface everything with `if not None`.
|
||||||
|
'''
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.done = do_nothing
|
||||||
|
self.set_total = do_nothing
|
||||||
|
self.set_topic = do_nothing
|
||||||
|
self.step = do_nothing
|
||||||
|
|
||||||
|
# Common presets ###################################################################################
|
||||||
|
|
||||||
|
def total_tostring_bytestring(**kwargs):
|
||||||
|
def total_tostring(total):
|
||||||
|
if total is None:
|
||||||
|
return '?'
|
||||||
|
return bytestring.bytestring(total, **kwargs)
|
||||||
|
return total_tostring
|
||||||
|
|
||||||
|
def value_tostring_bytestring(**kwargs):
|
||||||
|
decimals = kwargs.get('decimal_places', 3)
|
||||||
|
just = 8 + decimals + (1 if decimals else 0)
|
||||||
|
def value_tostring(value, total=0, total_string=''):
|
||||||
|
# The longest possible output looks like "1000.00 mib".
|
||||||
|
return bytestring.bytestring(value, **kwargs).rjust(just, ' ')
|
||||||
|
return value_tostring
|
||||||
|
|
||||||
|
def total_tostring_comma(total):
|
||||||
|
if total is None:
|
||||||
|
return '?'
|
||||||
|
return f'{total:,}'
|
||||||
|
|
||||||
|
def value_tostring_comma(value, total=0, total_string=''):
|
||||||
|
return f'{value:,}'.rjust(len(total_string))
|
||||||
|
|
||||||
|
def bar1_comma(*args, **kwargs):
|
||||||
|
return Bar1(
|
||||||
|
*args,
|
||||||
|
total_tostring=total_tostring_comma,
|
||||||
|
value_tostring=value_tostring_comma,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def bar1_bytestring(*args, **kwargs):
|
||||||
|
return Bar1(
|
||||||
|
*args,
|
||||||
|
total_tostring=total_tostring_bytestring(),
|
||||||
|
value_tostring=value_tostring_bytestring(),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Helper functions #################################################################################
|
||||||
|
|
||||||
|
def do_nothing(*args, **kwargs):
|
||||||
|
return
|
||||||
|
|
||||||
|
def normalize(progressbar, total=None, *, topic=None) -> typing.Union[Progress, None]:
|
||||||
|
if progressbar is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif isinstance(progressbar, Progress):
|
||||||
|
progressbar.set_total(total=total)
|
||||||
|
progressbar.set_topic(topic=topic)
|
||||||
|
return progressbar
|
||||||
|
|
||||||
|
elif callable(progressbar):
|
||||||
|
return progressbar(total=total, topic=topic)
|
||||||
|
|
||||||
|
raise TypeError(f'Could not normalize {progressbar} into a Progress instance.')
|
||||||
|
|
||||||
|
normalize_progressbar = normalize
|
||||||
|
|
||||||
|
def normalize_ratelimiter(ratelimit):
|
||||||
|
if ratelimit is None:
|
||||||
|
return None
|
||||||
|
elif isinstance(ratelimit, (int, float)):
|
||||||
|
return ratelimiter.Ratelimiter(allowance=ratelimit, mode='reject')
|
||||||
|
elif isinstance(ratelimit, ratelimiter.Ratelimiter):
|
||||||
|
return ratelimit
|
||||||
|
|
||||||
|
def should_stderr():
|
||||||
|
'''
|
||||||
|
Returns whether stderr exists and is suitable for printing a progress bar.
|
||||||
|
|
||||||
|
If the return value of this function is False, then there is no point
|
||||||
|
using a progressbar class.
|
||||||
|
'''
|
||||||
|
return pipeable.stderr_tty()
|
Loading…
Reference in a new issue