diff --git a/.coveragerc b/.coveragerc index dd994896..995b08fe 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,6 +6,7 @@ source = omit = */mock/* */nose/* + .tox/* [paths] source = progressbar diff --git a/.travis.yml b/.travis.yml index 835c6fb2..45f5c4fb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,9 +9,9 @@ python: - '3.7' - pypy install: -- pip install . -- pip install -r tests/requirements.txt -before_script: flake8 progressbar tests +- pip install -U . +- pip install -U -r tests/requirements.txt +before_script: flake8 progressbar tests examples script: - python setup.py test - python examples.py diff --git a/examples.py b/examples.py index 05099efc..4f55fe90 100644 --- a/examples.py +++ b/examples.py @@ -17,10 +17,10 @@ def example(fn): '''Wrap the examples so they generate readable output''' @functools.wraps(fn) - def wrapped(): + def wrapped(*args, **kwargs): try: sys.stdout.write('Running: %s\n' % fn.__name__) - fn() + fn(*args, **kwargs) sys.stdout.write('\n') except KeyboardInterrupt: sys.stdout.write('\nSkipping example.\n\n') @@ -31,6 +31,14 @@ def wrapped(): return wrapped +@example +def fast_example(): + ''' Updates bar really quickly to cause flickering ''' + with progressbar.ProgressBar(widgets=[progressbar.Bar()]) as bar: + for i in range(100): + bar.update(int(i / 10), force=True) + + @example def shortcut_example(): for i in progressbar.progressbar(range(10)): @@ -73,7 +81,8 @@ def basic_widget_example(): @example def color_bar_example(): - widgets = ['\x1b[33mColorful example\x1b[39m', progressbar.Percentage(), progressbar.Bar(marker='\x1b[32m#\x1b[39m')] + widgets = ['\x1b[33mColorful example\x1b[39m', progressbar.Percentage(), + progressbar.Bar(marker='\x1b[32m#\x1b[39m')] bar = progressbar.ProgressBar(widgets=widgets, max_value=10).start() for i in range(10): # do something @@ -82,6 +91,66 @@ def color_bar_example(): bar.finish() +@example +def multi_range_bar_example(): + markers = [ + '\x1b[32m█\x1b[39m', # Done + '\x1b[33m#\x1b[39m', # Processing + '\x1b[31m.\x1b[39m', # Scheduling + ' ' # Not started + ] + widgets = [progressbar.MultiRangeBar("amounts", markers=markers)] + amounts = [0] * (len(markers) - 1) + [25] + + with progressbar.ProgressBar(widgets=widgets, max_value=10).start() as bar: + while True: + incomplete_items = [ + idx + for idx, amount in enumerate(amounts) + for i in range(amount) + if idx != 0 + ] + if not incomplete_items: + break + which = random.choice(incomplete_items) + amounts[which] -= 1 + amounts[which - 1] += 1 + + bar.update(amounts=amounts, force=True) + time.sleep(0.02) + + +@example +def multi_progress_bar_example(left=True): + jobs = [ + # Each job takes between 1 and 10 steps to complete + [0, random.randint(1, 10)] + for i in range(25) # 25 jobs total + ] + + widgets = [ + progressbar.Percentage(), + ' ', progressbar.MultiProgressBar('jobs', fill_left=left), + ] + + max_value = sum([total for progress, total in jobs]) + with progressbar.ProgressBar(widgets=widgets, max_value=max_value) as bar: + while True: + incomplete_jobs = [ + idx + for idx, (progress, total) in enumerate(jobs) + if progress < total + ] + if not incomplete_jobs: + break + which = random.choice(incomplete_jobs) + jobs[which][0] += 1 + progress = sum([progress for progress, total in jobs]) + + bar.update(progress, jobs=jobs, force=True) + time.sleep(0.02) + + @example def file_transfer_example(): widgets = [ @@ -386,9 +455,9 @@ def increment_bar_with_output_redirection(): ' ', progressbar.ETA(), ' ', progressbar.FileTransferSpeed(), ] - bar = progressbar.ProgressBar(widgets=widgets, max_value=1000, + bar = progressbar.ProgressBar(widgets=widgets, max_value=100, redirect_stdout=True).start() - for i in range(100): + for i in range(10): # do something time.sleep(0.01) bar += 10 @@ -507,11 +576,12 @@ def user_variables(): with progressbar.ProgressBar( prefix='{variables.task} >> {variables.subtask}', variables={'task': '--', 'subtask': '--'}, - max_value=10*num_subtasks) as bar: + max_value=10 * num_subtasks) as bar: for tasks_name, subtasks in tasks.items(): for subtask_name in subtasks: for i in range(10): - bar.update(bar.value+1, task=tasks_name, subtask=subtask_name) + bar.update(bar.value + 1, task=tasks_name, + subtask=subtask_name) time.sleep(0.1) diff --git a/progressbar/__init__.py b/progressbar/__init__.py index bc4bfd91..b108c419 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -23,6 +23,9 @@ ReverseBar, BouncingBar, RotatingMarker, + VariableMixin, + MultiRangeBar, + MultiProgressBar, Variable, DynamicMessage, FormatCustomText, @@ -66,6 +69,9 @@ 'ProgressBar', 'DataTransferBar', 'RotatingMarker', + 'VariableMixin', + 'MultiRangeBar', + 'MultiProgressBar', 'Variable', 'DynamicMessage', 'FormatCustomText', diff --git a/progressbar/bar.py b/progressbar/bar.py index b82d547f..97a7882d 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -33,6 +33,7 @@ class ProgressBarMixinBase(object): def __init__(self, **kwargs): self._finished = False + self.offset = None def start(self, **kwargs): pass @@ -68,14 +69,7 @@ def __init__(self, fd=sys.stderr, is_terminal=None, line_breaks=None, self.fd = fd # Check if this is an interactive terminal - if is_terminal is None: - is_terminal = utils.env_flag('PROGRESSBAR_IS_TERMINAL', None) - if is_terminal is None: # pragma: no cover - try: - is_terminal = fd.isatty() - except Exception: - is_terminal = False - self.is_terminal = is_terminal + self.is_terminal = is_terminal = utils.is_terminal(fd, is_terminal) # Check if it should overwrite the current line (suitable for # iteractive terminals) or write line breaks (suitable for log files) @@ -93,6 +87,17 @@ def __init__(self, fd=sys.stderr, is_terminal=None, line_breaks=None, ProgressBarMixinBase.__init__(self, **kwargs) + def add_rewrite_or_offset(self, line): + if self.line_breaks: + line = line.rstrip() + '\n' + elif self.offset: + # line = utils.add_ansi_offset(line, *self.offset) + line = utils.add_ansi_offset(line + '\n', *self.offset) + else: + line = '\r' + line + + return line + def update(self, *args, **kwargs): ProgressBarMixinBase.update(self, *args, **kwargs) @@ -100,12 +105,7 @@ def update(self, *args, **kwargs): if not self.enable_colors: line = utils.no_color(line) - if self.line_breaks: - line = line.rstrip() + '\n' - else: - line = '\r' + line - - self.fd.write(line) + self.fd.write(self.add_rewrite_or_offset(line)) def finish(self, *args, **kwargs): # pragma: no cover if self._finished: @@ -114,7 +114,7 @@ def finish(self, *args, **kwargs): # pragma: no cover end = kwargs.pop('end', '\n') ProgressBarMixinBase.finish(self, *args, **kwargs) - if end and not self.line_breaks: + if end and not self.line_breaks and not self.offset: self.fd.write(end) self.fd.flush() @@ -180,8 +180,9 @@ def start(self, *args, **kwargs): DefaultFdMixin.start(self, *args, **kwargs) def update(self, value=None): - if not self.line_breaks: + if not self.line_breaks and utils.streams.needs_clear(): self.fd.write('\r' + ' ' * self.term_width + '\r') + utils.streams.flush() DefaultFdMixin.update(self, value=value) @@ -228,15 +229,15 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase): prefix (str): Prefix the progressbar with the given string suffix (str): Prefix the progressbar with the given string variables (dict): User-defined variables variables that can be used - from a label using `format="{variables.my_var}"`. These values can - be updated using `bar.update(my_var="newValue")` This can also be - used to set initial values for `Variable`s widgets + from a label using `format='{variables.my_var}'`. These values can + be updated using `bar.update(my_var='newValue')` This can also be + used to set initial values for variables' widgets A common way of using it is like: >>> progress = ProgressBar().start() >>> for i in range(100): - ... progress.update(i+1) + ... progress.update(i + 1) ... # do something ... >>> progress.finish() @@ -342,7 +343,7 @@ def __init__(self, min_value=0, max_value=None, widgets=None, # A dictionary of names that can be used by Variable and FormatWidget self.variables = utils.AttributeDict(variables or {}) for widget in (self.widgets or []): - if isinstance(widget, widgets_module.Variable): + if isinstance(widget, widgets_module.VariableMixin): if widget.name not in self.variables: self.variables[widget.name] = None @@ -734,7 +735,7 @@ def start(self, max_value=None, init=True): self.next_update = 0 if self.max_value is not base.UnknownLength and self.max_value < 0: - raise ValueError('Value out of range') + raise ValueError('max_value out of range, got %r' % self.max_value) self.start_time = self.last_update_time = datetime.now() self._last_update_timer = timeit.default_timer() diff --git a/progressbar/utils.py b/progressbar/utils.py index aba7477c..191c0b6b 100644 --- a/progressbar/utils.py +++ b/progressbar/utils.py @@ -6,6 +6,7 @@ import sys import logging import datetime +import functools from python_utils.time import timedelta_to_seconds, epoch, format_time from python_utils.converters import scale_1024 from python_utils.terminal import get_terminal_size @@ -20,6 +21,87 @@ assert epoch +def is_terminal(fd, is_terminal=None): + if is_terminal is None: + if 'JPY_PARENT_PID' in os.environ: + is_terminal = True + elif 'VIM' in os.environ: + is_terminal = True + else: + is_terminal = env_flag('PROGRESSBAR_IS_TERMINAL', None) + + if is_terminal is None: # pragma: no cover + try: + is_terminal = fd.isatty() + except Exception: + is_terminal = False + + return is_terminal + + +def ansi_esc(value, command): + return u'\u001B[{}{}'.format(value, command) + + +class ANSI: + + # For more options: + # https://en.wikipedia.org/wiki/ANSI_escape_code#Terminal_output_sequences + + def esc(command, default_value=None): + def _esc(value=default_value): + return u'\u001B[{}{}'.format(value, command) + return u'\u001B[{}{}{}{}'.format(value, command, value, command) + + return _esc + + def y(line, y0, y1=None): + a, b = ANSI.up, ANSI.down + if y0 < 0: + a, b = b, a + + if y1 is None: + y1 = y0 + + return a(y0) + line + b(y1) + + def x(line, x0, x1=None): + a, b = ANSI.left, ANSI.right + if x0 < 0: + a, b = b, a + + if x1 is None: + x1 = x0 + + return a(x0) + line + b(x1) + + up = esc('A') + up = esc('F') + down = esc('B') + down = esc('E') + right = esc('C') + left = esc('D') + begin_of_line = esc('G', 1) + clear = esc('K', 2) + # clear = esc('J', 1) + + +def add_ansi_offset(line, x0=0, y0=0, x1=None, y1=None): + + line = '({x0},{y0})({x1},{y1}) {line}'.format( + x0=x0, y0=y0, x1=x1, y1=y0, line=line) + + # line = ANSI.clear() + line + + if x0 or x1: + line = ANSI.x(line, x0, x1) + + if y0 or y1: + line = ANSI.y(line, y0, y1) + + return line + + def deltas_to_seconds(*deltas, **kwargs): # default=ValueError): ''' Convert timedeltas and seconds as int to seconds as float while coalescing @@ -117,22 +199,60 @@ def env_flag(name, default=None): class WrappingIO: - def __init__(self, target, capturing=False, listeners=set()): + def __init__(self, target, capturing=False, listeners=None): self.buffer = six.StringIO() self.target = target self.capturing = capturing - self.listeners = listeners + self.listeners = list(listeners or []) + self.needs_clear = False + self.update_offset() + + def update_offset(self): + x = y = None + for listener in self.listeners: + if listener.offset: + x_new, y_new = listener.offset + x = max(x, x_new) + y = max(y, y_new) + + if x is None and y is None: + offset = None + else: + offset = x or 0, y or 0 + + self.offset = offset def write(self, value): + # value = ANSI.up(4) + value + ANSI.down(4) + + if self.offset: + ... + # value = ANSI.up(4) + value + ANSI.down(1) + # value = add_ansi_offset(value, *self.offset) + # print('writing %r\n\n\n\n\n' % value, file=sys.__stderr__) + if self.capturing: - self.buffer.write(value) + # value = ANSI.y(value + '\n', 4, 1) + # if '\n' in value: + # value = value + 2 * '\n' + ANSI.up(2) + + self.buffer.write(ANSI.up(4)) + self.buffer.write(value + '\n' * 3) if '\n' in value: + # self.buffer.write('\n') + # self.buffer.write(ANSI.up(3)) + # self.buffer.write('\n' + ANSI.up(1)) + self.needs_clear = not self.offset + self.needs_clear = True for listener in self.listeners: # pragma: no branch listener.update() + + self.buffer.write(ANSI.down(4)) else: self.target.write(value) def flush(self): + self.needs_clear = False self.buffer.flush() def _flush(self): @@ -155,7 +275,7 @@ def __init__(self): self.wrapped_stderr = 0 self.wrapped_excepthook = 0 self.capturing = 0 - self.listeners = set() + self.listeners = [] if env_flag('WRAP_STDOUT', default=False): # pragma: no cover self.wrap_stdout() @@ -165,7 +285,7 @@ def __init__(self): def start_capturing(self, bar=None): if bar: # pragma: no branch - self.listeners.add(bar) + self.listeners.append(bar) self.capturing += 1 self.update_capturing() @@ -249,6 +369,11 @@ def unwrap_stderr(self): sys.stderr = self.original_stderr self.wrapped_stderr = 0 + def needs_clear(self): # pragma: no cover + stdout_needs_clear = getattr(self.stdout, 'needs_clear', False) + stderr_needs_clear = getattr(self.stderr, 'needs_clear', False) + return stderr_needs_clear or stdout_needs_clear + def flush(self): if self.wrapped_stdout: # pragma: no branch try: diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 728856a9..73016e50 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from __future__ import absolute_import from __future__ import division from __future__ import print_function @@ -743,7 +744,109 @@ def __call__(self, progress, data): self, progress, self.mapping, self.format) -class Variable(FormatWidgetMixin, WidgetBase): +class VariableMixin(object): + '''Mixin to display a custom user variable ''' + + def __init__(self, name, **kwargs): + if not isinstance(name, str): + raise TypeError('Variable(): argument must be a string') + if len(name.split()) > 1: + raise ValueError('Variable(): argument must be single word') + self.name = name + + +class MultiRangeBar(Bar, VariableMixin): + ''' + A bar with multiple sub-ranges, each represented by a different symbol + + The various ranges are represented on a user-defined variable, formatted as + + .. code-block:: python + + [ + ['Symbol1', amount1], + ['Symbol2', amount2], + ... + ] + ''' + + def __init__(self, name, markers, **kwargs): + VariableMixin.__init__(self, name) + Bar.__init__(self, **kwargs) + self.markers = [ + string_or_lambda(marker) + for marker in markers + ] + + def get_values(self, progress, data): + return data['variables'][self.name] or [] + + def __call__(self, progress, data, width): + '''Updates the progress bar and its subcomponents''' + + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + values = self.get_values(progress, data) + + values_sum = sum(values) + if width and values_sum: + middle = '' + values_accumulated = 0 + width_accumulated = 0 + for marker, value in zip(self.markers, values): + marker = converters.to_unicode(marker(progress, data, width)) + assert utils.len_color(marker) == 1 + + values_accumulated += value + item_width = int(values_accumulated / values_sum * width) + item_width -= width_accumulated + width_accumulated += item_width + middle += item_width * marker + else: + fill = converters.to_unicode(self.fill(progress, data, width)) + assert utils.len_color(fill) == 1 + middle = fill * width + + return left + middle + right + + +class MultiProgressBar(MultiRangeBar): + def __init__(self, + name, + # NOTE: the markers are not whitespace even though some + # terminals don't show the characters correctly! + markers=' ▁▂▃▄▅▆▇█', + **kwargs): + MultiRangeBar.__init__(self, name=name, + markers=list(reversed(markers)), **kwargs) + + def get_values(self, progress, data): + ranges = [0] * len(self.markers) + for progress in data['variables'][self.name] or []: + if not isinstance(progress, (int, float)): + # Progress is (value, max) + progress_value, progress_max = progress + progress = float(progress_value) / float(progress_max) + + if progress < 0 or progress > 1: + raise ValueError( + 'Range value needs to be in the range [0..1], got %s' % + progress) + + range_ = progress * (len(ranges) - 1) + pos = int(range_) + frac = range_ % 1 + ranges[pos] += (1 - frac) + if (frac): + ranges[pos + 1] += (frac) + + if self.fill_left: + ranges = list(reversed(ranges)) + return ranges + + +class Variable(FormatWidgetMixin, VariableMixin, WidgetBase): '''Displays a custom variable.''' def __init__(self, name, format='{name}: {formatted_value}', @@ -752,14 +855,9 @@ def __init__(self, name, format='{name}: {formatted_value}', self.format = format self.width = width self.precision = precision - if not isinstance(name, str): - raise TypeError('Variable(): argument must be a string') - if len(name.split()) > 1: - raise ValueError('Variable(): argument must be single word') + VariableMixin.__init__(self, name=name) WidgetBase.__init__(self, **kwargs) - self.name = name - def __call__(self, progress, data): value = data['variables'][self.name] context = data.copy() diff --git a/tests/test_monitor_progress.py b/tests/test_monitor_progress.py index face1f21..d55c86ab 100644 --- a/tests/test_monitor_progress.py +++ b/tests/test_monitor_progress.py @@ -172,32 +172,18 @@ def test_no_line_breaks(testdir): line_breaks=False, items=list(range(5)), ))) - pprint.pprint(result.stderr.str(), width=70) - assert result.stderr.str() == u'\n'.join(( - u'', - u' ', + pprint.pprint(result.stderr.lines, width=70) + assert result.stderr.lines == [ u'', u'N/A%| |', - u' ', - u'', u' 20%|########## |', - u' ', - u'', u' 40%|##################### |', - u' ', - u'', u' 60%|################################ |', - u' ', - u'', u' 80%|########################################### |', - u' ', - u'', u'100%|######################################################|', u'', - u' ', - u'', u'100%|######################################################|' - )) + ] def test_colors(testdir): diff --git a/tests/test_multibar.py b/tests/test_multibar.py new file mode 100644 index 00000000..fe1c569f --- /dev/null +++ b/tests/test_multibar.py @@ -0,0 +1,20 @@ +import pytest +import progressbar + + +def test_multi_progress_bar_out_of_range(): + widgets = [ + progressbar.MultiProgressBar('multivalues'), + ] + + bar = progressbar.ProgressBar(widgets=widgets, max_value=10) + with pytest.raises(ValueError): + bar.update(multivalues=[123]) + + with pytest.raises(ValueError): + bar.update(multivalues=[-1]) + + +def test_multi_progress_bar_fill_left(): + import examples + return examples.multi_progress_bar_example(False) diff --git a/tests/test_stream.py b/tests/test_stream.py index 0c540b47..235f174a 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -1,3 +1,5 @@ +from __future__ import print_function + import io import sys import pytest @@ -74,6 +76,27 @@ def test_fd_as_io_stream(): stream.close() +def test_no_newlines(): + kwargs = dict( + redirect_stderr=True, + redirect_stdout=True, + line_breaks=False, + is_terminal=True, + ) + + with progressbar.ProgressBar(**kwargs) as bar: + for i in range(5): + bar.update(i) + + for i in range(5, 10): + try: + print('\n\n', file=progressbar.streams.stdout) + print('\n\n', file=progressbar.streams.stderr) + except ValueError: + pass + bar.update(i) + + @pytest.mark.parametrize('stream', [sys.__stdout__, sys.__stderr__]) def test_fd_as_standard_streams(stream): with progressbar.ProgressBar(fd=stream) as pb: diff --git a/tests/test_timed.py b/tests/test_timed.py index ecebdc17..75ee1665 100644 --- a/tests/test_timed.py +++ b/tests/test_timed.py @@ -94,12 +94,14 @@ def calculate_eta(self, value, elapsed): '''Capture the widget output''' data = dict( value=value, - elapsed=elapsed, + elapsed=int(elapsed), ) datas.append(data) return 0, 0 monkeypatch.setattr(progressbar.FileTransferSpeed, '_speed', calculate_eta) + monkeypatch.setattr(progressbar.AdaptiveTransferSpeed, '_speed', + calculate_eta) for widget in widgets: widget.INTERVAL = interval @@ -120,6 +122,9 @@ def calculate_eta(self, value, elapsed): time.sleep(10) p.finish() + import pprint + pprint.pprint(datas) + for i, (a, b) in enumerate(zip(datas[::2], datas[1::2])): # Because the speed is identical initially, the results should be the # same for adaptive and regular transfer speed. Only when the speed diff --git a/tests/test_utils.py b/tests/test_utils.py index d95d8e58..3f292bfd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,3 +1,4 @@ +import io import pytest import progressbar @@ -26,3 +27,30 @@ def test_env_flag(value, expected, monkeypatch): assert progressbar.utils.env_flag('TEST_ENV') == expected monkeypatch.undo() + + +def test_is_terminal(monkeypatch): + fd = io.StringIO() + + monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL', raising=False) + monkeypatch.delenv('JPY_PARENT_PID', raising=False) + + assert progressbar.utils.is_terminal(fd) is False + assert progressbar.utils.is_terminal(fd, True) is True + assert progressbar.utils.is_terminal(fd, False) is False + + monkeypatch.setenv('JPY_PARENT_PID', '123') + assert progressbar.utils.is_terminal(fd) is True + monkeypatch.delenv('JPY_PARENT_PID') + + # Sanity check + assert progressbar.utils.is_terminal(fd) is False + + monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'true') + assert progressbar.utils.is_terminal(fd) is True + monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'false') + assert progressbar.utils.is_terminal(fd) is False + monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL') + + # Sanity check + assert progressbar.utils.is_terminal(fd) is False