From c7c62ff86ad775f4741176da820bc067eadf68ee Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 25 Jan 2024 03:49:21 +0100 Subject: [PATCH 01/17] testing multibar examples --- examples.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/examples.py b/examples.py index 8b7247c..c23b7b4 100644 --- a/examples.py +++ b/examples.py @@ -4,6 +4,7 @@ import functools import random import sys +import threading import time import typing @@ -50,6 +51,68 @@ def prefixed_shortcut_example(): time.sleep(0.1) +@example +def parallel_bars_multibar_example(): + BARS = 5 + N = 50 + + def do_something(bar): + for i in bar(range(N)): + # Sleep up to 0.1 seconds + time.sleep(random.random() * 0.1) + + with (progressbar.MultiBar() as multibar): + bar_labels = [] + for i in range(BARS): + # Get a progressbar + bar_label = 'Bar #%d' % i + bar_labels.append(bar_label) + bar = multibar[bar_label] + + for i in range(N * BARS): + + time.sleep(0.005) + + bar_i = random.randrange(0, BARS) + bar_label = bar_labels[bar_i] + # Increment one of the progress bars at random + multibar[bar_label].increment() + +@example +def multiple_bars_line_offset_example(): + BARS = 5 + N = 100 + + # Construct the list of progress bars with the `line_offset` so they draw + # below each other + bars = [] + for i in range(BARS): + bars.append( + progressbar.ProgressBar( + max_value=N, + # We add 1 to the line offset to account for the `print_fd` + line_offset=i + 1, + max_error=False, + ) + ) + + # Create a file descriptor for regular printing as well + print_fd = progressbar.LineOffsetStreamWrapper(lines=0, stream=sys.stdout) + + # The progress bar updates, normally you would do something useful here + for i in range(N * BARS): + time.sleep(0.005) + + # Increment one of the progress bars at random + bars[random.randrange(0, BARS)].increment() + + # Cleanup the bars + for bar in bars: + bar.finish() + # Add a newline to make sure the next print starts on a new line + print() + + @example def templated_shortcut_example(): for i in progressbar.progressbar(range(10), suffix='{seconds_elapsed:.1}'): From 698457627e6c4e0eb05b0f08d83ff32fecea137a Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 25 Jan 2024 03:49:22 +0100 Subject: [PATCH 02/17] Added smoothing eta to fix #280. The previous algorithm could be really jumpy in some cases and has been replaced with an exponential moving average. Double exponential moving average is also available --- examples.py | 12 +- progressbar/__init__.py | 7 + progressbar/bar.py | 4 +- progressbar/widgets.py | 523 ++++++++++++++++++++++------------------ 4 files changed, 301 insertions(+), 245 deletions(-) diff --git a/examples.py b/examples.py index c23b7b4..55c98da 100644 --- a/examples.py +++ b/examples.py @@ -644,13 +644,17 @@ def eta_types_demonstration(): progressbar.Percentage(), ' ETA: ', progressbar.ETA(), - ' Adaptive ETA: ', + ' Adaptive : ', progressbar.AdaptiveETA(), - ' Absolute ETA: ', + ' Smoothing(a=0.1): ', + progressbar.SmoothingETA(smoothing_parameters=dict(alpha=0.1)), + ' Smoothing(a=0.9): ', + progressbar.SmoothingETA(smoothing_parameters=dict(alpha=0.9)), + ' Absolute: ', progressbar.AbsoluteETA(), - ' Transfer Speed: ', + ' Transfer: ', progressbar.FileTransferSpeed(), - ' Adaptive Transfer Speed: ', + ' Adaptive T: ', progressbar.AdaptiveTransferSpeed(), ' ', progressbar.Bar(), diff --git a/progressbar/__init__.py b/progressbar/__init__.py index 4382499..7da3977 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -7,10 +7,12 @@ from .shortcuts import progressbar from .terminal.stream import LineOffsetStreamWrapper from .utils import len_color, streams +from .algorithms import ExponentialMovingAverage, SmoothingAlgorithm, DoubleExponentialMovingAverage from .widgets import ( ETA, AbsoluteETA, AdaptiveETA, + SmoothingETA, AdaptiveTransferSpeed, AnimatedMarker, Bar, @@ -36,6 +38,7 @@ Variable, VariableMixin, ) +from .algorithms import ExponentialMovingAverage, SmoothingAlgorithm __date__ = str(date.today()) __all__ = [ @@ -46,6 +49,10 @@ 'ETA', 'AdaptiveETA', 'AbsoluteETA', + 'SmoothingETA', + 'SmoothingAlgorithm', + 'ExponentialMovingAverage', + 'DoubleExponentialMovingAverage', 'DataSize', 'FileTransferSpeed', 'AdaptiveTransferSpeed', diff --git a/progressbar/bar.py b/progressbar/bar.py index d722100..01620f9 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -776,7 +776,7 @@ def default_widgets(self): ' ', widgets.Timer(**self.widget_kwargs), ' ', - widgets.AdaptiveETA(**self.widget_kwargs), + widgets.SmoothingETA(**self.widget_kwargs), ] else: return [ @@ -1071,7 +1071,7 @@ def default_widgets(self): ' ', widgets.Timer(), ' ', - widgets.AdaptiveETA(), + widgets.SmoothingETA(), ] else: return [ diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 40f2972..f063bc8 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -6,14 +6,13 @@ import functools import logging import typing - # Ruff is being stupid and doesn't understand `ClassVar` if it comes from the # `types` module from typing import ClassVar from python_utils import containers, converters, types -from . import base, terminal, utils +from . import base, terminal, utils, algorithms from .terminal import colors if types.TYPE_CHECKING: @@ -89,8 +88,8 @@ def wrap(*args, **kwargs): def create_marker(marker, wrap=None): def _marker(progress, data, width): if ( - progress.max_value is not base.UnknownLength - and progress.max_value > 0 + progress.max_value is not base.UnknownLength + and progress.max_value > 0 ): length = int(progress.value / progress.max_value * width) return marker * length @@ -100,7 +99,7 @@ def _marker(progress, data, width): if isinstance(marker, str): marker = converters.to_unicode(marker) assert ( - utils.len_color(marker) == 1 + utils.len_color(marker) == 1 ), 'Markers are required to be 1 char' return wrapper(_marker, wrap) else: @@ -128,18 +127,18 @@ def __init__(self, format: str, new_style: bool = False, **kwargs): self.format = format def get_format( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ) -> str: return format or self.format def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ) -> str: '''Formats the widget into a string.''' format_ = self.get_format(progress, data, format) @@ -278,11 +277,11 @@ def _apply_colors(self, text: str, data: Data) -> str: return text def __init__( - self, - *args, - fixed_colors=None, - gradient_colors=None, - **kwargs, + self, + *args, + fixed_colors=None, + gradient_colors=None, + **kwargs, ): if fixed_colors is not None: self._fixed_colors.update(fixed_colors) @@ -306,10 +305,10 @@ class AutoWidthWidgetBase(WidgetBase, metaclass=abc.ABCMeta): @abc.abstractmethod def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, ) -> str: '''Updates the widget providing the total width the widget must fill. @@ -355,10 +354,10 @@ def __init__(self, format: str, **kwargs): WidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): for name, (key, transform) in self.mapping.items(): with contextlib.suppress(KeyError, ValueError, IndexError): @@ -417,10 +416,10 @@ class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta): ''' def __init__( - self, - samples=datetime.timedelta(seconds=2), - key_prefix=None, - **kwargs, + self, + samples=datetime.timedelta(seconds=2), + key_prefix=None, + **kwargs, ): self.samples = samples self.key_prefix = (key_prefix or self.__class__.__name__) + '_' @@ -439,10 +438,10 @@ def get_sample_values(self, progress: ProgressBarMixinBase, data: Data): ) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - delta: bool = False, + self, + progress: ProgressBarMixinBase, + data: Data, + delta: bool = False, ): sample_times = self.get_sample_times(progress, data) sample_values = self.get_sample_values(progress, data) @@ -461,9 +460,9 @@ def __call__( minimum_time = progress.last_update_time - self.samples minimum_value = sample_values[-1] while ( - sample_times[2:] - and minimum_time > sample_times[1] - and minimum_value > sample_values[1] + sample_times[2:] + and minimum_time > sample_times[1] + and minimum_value > sample_values[1] ): sample_times.pop(0) sample_values.pop(0) @@ -485,13 +484,13 @@ class ETA(Timer): '''WidgetBase which attempts to estimate the time of arrival.''' def __init__( - self, - format_not_started='ETA: --:--:--', - format_finished='Time: %(elapsed)8s', - format='ETA: %(eta)8s', - format_zero='ETA: 00:00:00', - format_na='ETA: N/A', - **kwargs, + self, + format_not_started='ETA: --:--:--', + format_finished='Time: %(elapsed)8s', + format='ETA: %(eta)8s', + format_zero='ETA: 00:00:00', + format_na='ETA: N/A', + **kwargs, ): if '%s' in format and '%(eta)s' not in format: format = format.replace('%s', '%(eta)s') @@ -504,11 +503,11 @@ def __init__( self.format_NA = format_na def _calculate_eta( - self, - progress: ProgressBarMixinBase, - data: Data, - value, - elapsed, + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, ): '''Updates the widget to show the ETA or total time when finished.''' if elapsed: @@ -520,11 +519,11 @@ def _calculate_eta( return 0 def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - value=None, - elapsed=None, + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, ): '''Updates the widget to show the ETA or total time when finished.''' if value is None: @@ -568,11 +567,11 @@ class AbsoluteETA(ETA): '''Widget which attempts to estimate the absolute time of arrival.''' def _calculate_eta( - self, - progress: ProgressBarMixinBase, - data: Data, - value, - elapsed, + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, ): eta_seconds = ETA._calculate_eta(self, progress, data, value, elapsed) now = datetime.datetime.now() @@ -582,11 +581,11 @@ def _calculate_eta( return datetime.datetime.max def __init__( - self, - format_not_started='Estimated finish time: ----/--/-- --:--:--', - format_finished='Finished at: %(elapsed)s', - format='Estimated finish time: %(eta)s', - **kwargs, + self, + format_not_started='Estimated finish time: ----/--/-- --:--:--', + format_finished='Finished at: %(elapsed)s', + format='Estimated finish time: %(eta)s', + **kwargs, ): ETA.__init__( self, @@ -603,17 +602,24 @@ class AdaptiveETA(ETA, SamplesMixin): Uses a sampled average of the speed based on the 10 last updates. Very convenient for resuming the progress halfway. ''' - - def __init__(self, **kwargs): + exponential_smoothing: bool + exponential_smoothing_factor: float + + def __init__(self, + exponential_smoothing=True, + exponential_smoothing_factor=0.1, + **kwargs): + self.exponential_smoothing = exponential_smoothing + self.exponential_smoothing_factor = exponential_smoothing_factor ETA.__init__(self, **kwargs) SamplesMixin.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - value=None, - elapsed=None, + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, ): elapsed, value = SamplesMixin.__call__( self, @@ -628,6 +634,45 @@ def __call__( return ETA.__call__(self, progress, data, value=value, elapsed=elapsed) +class SmoothingETA(ETA): + ''' + WidgetBase which attempts to estimate the time of arrival using an + exponential moving average (EMA) of the speed. + + EMA applies more weight to recent data points and less to older ones, + and doesn't require storing all past values. This approach works well + with varying data points and smooths out fluctuations effectively. + ''' + smoothing_algorithm: algorithms.SmoothingAlgorithm + smoothing_parameters: dict[str, float] + + def __init__(self, + smoothing_algorithm: type[algorithms.SmoothingAlgorithm]= + algorithms.ExponentialMovingAverage, + smoothing_parameters: dict[str, float] | None = None, + **kwargs): + self.smoothing_parameters = smoothing_parameters or {} + self.smoothing_algorithm = smoothing_algorithm( + **(self.smoothing_parameters or {})) + ETA.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + if value is None: # pragma: no branch + value = data['value'] + + if elapsed is None: # pragma: no branch + elapsed = data['time_elapsed'] + + self.smoothing_algorithm.update(value, elapsed) + return ETA.__call__(self, progress, data, value=value, elapsed=elapsed) + + class DataSize(FormatWidgetMixin, WidgetBase): ''' Widget for showing an amount of data transferred/processed. @@ -637,12 +682,12 @@ class DataSize(FormatWidgetMixin, WidgetBase): ''' def __init__( - self, - variable='value', - format='%(scaled)5.1f %(prefix)s%(unit)s', - unit='B', - prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), - **kwargs, + self, + variable='value', + format='%(scaled)5.1f %(prefix)s%(unit)s', + unit='B', + prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), + **kwargs, ): self.variable = variable self.unit = unit @@ -651,10 +696,10 @@ def __init__( WidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): value = data[self.variable] if value is not None: @@ -675,12 +720,12 @@ class FileTransferSpeed(FormatWidgetMixin, TimeSensitiveWidgetBase): ''' def __init__( - self, - format='%(scaled)5.1f %(prefix)s%(unit)-s/s', - inverse_format='%(scaled)5.1f s/%(prefix)s%(unit)-s', - unit='B', - prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), - **kwargs, + self, + format='%(scaled)5.1f %(prefix)s%(unit)-s/s', + inverse_format='%(scaled)5.1f s/%(prefix)s%(unit)-s', + unit='B', + prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), + **kwargs, ): self.unit = unit self.prefixes = prefixes @@ -693,11 +738,11 @@ def _speed(self, value, elapsed): return utils.scale_1024(speed, len(self.prefixes)) def __call__( - self, - progress: ProgressBarMixinBase, - data, - value=None, - total_seconds_elapsed=None, + self, + progress: ProgressBarMixinBase, + data, + value=None, + total_seconds_elapsed=None, ): '''Updates the widget with the current SI prefixed speed.''' if value is None: @@ -709,10 +754,10 @@ def __call__( ) if ( - value is not None - and elapsed is not None - and elapsed > 2e-6 - and value > 2e-6 + value is not None + and elapsed is not None + and elapsed > 2e-6 + and value > 2e-6 ): # =~ 0 scaled, power = self._speed(value, elapsed) else: @@ -744,11 +789,11 @@ def __init__(self, **kwargs): SamplesMixin.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data, - value=None, - total_seconds_elapsed=None, + self, + progress: ProgressBarMixinBase, + data, + value=None, + total_seconds_elapsed=None, ): elapsed, value = SamplesMixin.__call__( self, @@ -765,13 +810,13 @@ class AnimatedMarker(TimeSensitiveWidgetBase): ''' def __init__( - self, - markers='|/-\\', - default=None, - fill='', - marker_wrap=None, - fill_wrap=None, - **kwargs, + self, + markers='|/-\\', + default=None, + fill='', + marker_wrap=None, + fill_wrap=None, + **kwargs, ): self.markers = markers self.marker_wrap = create_wrapper(marker_wrap) @@ -824,10 +869,10 @@ def __init__(self, format='%(value)d', **kwargs): WidgetBase.__init__(self, format=format, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format=None, + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, ): return FormatWidgetMixin.__call__(self, progress, data, format) @@ -857,10 +902,10 @@ def __init__(self, format='%(percentage)3d%%', na='N/A%%', **kwargs): WidgetBase.__init__(self, format=format, **kwargs) def get_format( - self, - progress: ProgressBarMixinBase, - data: Data, - format=None, + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, ): # If percentage is not available, display N/A% percentage = data.get('percentage', base.Undefined) @@ -888,10 +933,10 @@ def __init__(self, format=DEFAULT_FORMAT, **kwargs): self.max_width_cache = dict(default=self.max_width or 0) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format=None, + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, ): # If max_value is not available, display N/A if data.get('max_value'): @@ -926,12 +971,12 @@ def __call__( temporary_data['value'] = value if width := progress.custom_len( # pragma: no branch - FormatWidgetMixin.__call__( - self, - progress, - temporary_data, - format=format, - ), + FormatWidgetMixin.__call__( + self, + progress, + temporary_data, + format=format, + ), ): max_width = max(max_width or 0, width) @@ -951,14 +996,14 @@ class Bar(AutoWidthWidgetBase): bg: terminal.OptionalColor | None = None def __init__( - self, - marker='#', - left='|', - right='|', - fill=' ', - fill_left=True, - marker_wrap=None, - **kwargs, + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=True, + marker_wrap=None, + **kwargs, ): '''Creates a customizable progress bar. @@ -979,11 +1024,11 @@ def __init__( AutoWidthWidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - color=True, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, ): '''Updates the progress bar and its subcomponents.''' left = converters.to_unicode(self.left(progress, data, width)) @@ -1010,13 +1055,13 @@ class ReverseBar(Bar): '''A bar which has a marker that goes from right to left.''' def __init__( - self, - marker='#', - left='|', - right='|', - fill=' ', - fill_left=False, - **kwargs, + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=False, + **kwargs, ): '''Creates a customizable progress bar. @@ -1043,11 +1088,11 @@ class BouncingBar(Bar, TimeSensitiveWidgetBase): INTERVAL = datetime.timedelta(milliseconds=100) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - color=True, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, ): '''Updates the progress bar and its subcomponents.''' left = converters.to_unicode(self.left(progress, data, width)) @@ -1080,10 +1125,10 @@ class FormatCustomText(FormatWidgetMixin, WidgetBase): copy = False def __init__( - self, - format: str, - mapping: types.Optional[types.Dict[str, types.Any]] = None, - **kwargs, + self, + format: str, + mapping: types.Optional[types.Dict[str, types.Any]] = None, + **kwargs, ): self.format = format self.mapping = mapping or self.mapping @@ -1094,10 +1139,10 @@ def update_mapping(self, **mapping: types.Dict[str, types.Any]): self.mapping.update(mapping) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): return FormatWidgetMixin.__call__( self, @@ -1142,11 +1187,11 @@ def get_values(self, progress: ProgressBarMixinBase, data: Data): return data['variables'][self.name] or [] def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - color=True, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, ): '''Updates the progress bar and its subcomponents.''' left = converters.to_unicode(self.left(progress, data, width)) @@ -1178,12 +1223,12 @@ def __call__( 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, + self, + name, + # NOTE: the markers are not whitespace even though some + # terminals don't show the characters correctly! + markers=' ▁▂▃▄▅▆▇█', + **kwargs, ): MultiRangeBar.__init__( self, @@ -1244,11 +1289,11 @@ class GranularBar(AutoWidthWidgetBase): ''' def __init__( - self, - markers=GranularMarkers.smooth, - left='|', - right='|', - **kwargs, + self, + markers=GranularMarkers.smooth, + left='|', + right='|', + **kwargs, ): '''Creates a customizable progress bar. @@ -1265,10 +1310,10 @@ def __init__( AutoWidthWidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, ): left = converters.to_unicode(self.left(progress, data, width)) right = converters.to_unicode(self.right(progress, data, width)) @@ -1278,8 +1323,8 @@ def __call__( # mypy doesn't get that the first part of the if statement makes sure # we get the correct type if ( - max_value is not base.UnknownLength - and max_value > 0 # type: ignore + max_value is not base.UnknownLength + and max_value > 0 # type: ignore ): percent = progress.value / max_value # type: ignore else: @@ -1309,11 +1354,11 @@ def __init__(self, format, **kwargs): Bar.__init__(self, **kwargs) def __call__( # type: ignore - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - format: FormatString = None, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + format: FormatString = None, ): center = FormatLabel.__call__(self, progress, data, format=format) bar = Bar.__call__(self, progress, data, width, color=False) @@ -1324,18 +1369,18 @@ def __call__( # type: ignore center_right = center_left + center_len return ( - self._apply_colors( - bar[:center_left], - data, - ) - + self._apply_colors( - center, - data, - ) - + self._apply_colors( - bar[center_right:], - data, - ) + self._apply_colors( + bar[:center_left], + data, + ) + + self._apply_colors( + center, + data, + ) + + self._apply_colors( + bar[center_right:], + data, + ) ) @@ -1349,11 +1394,11 @@ def __init__(self, format='%(percentage)2d%%', na='N/A%%', **kwargs): FormatLabelBar.__init__(self, format, **kwargs) def __call__( # type: ignore - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - format: FormatString = None, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + format: FormatString = None, ): return super().__call__(progress, data, width, format=format) @@ -1362,12 +1407,12 @@ class Variable(FormatWidgetMixin, VariableMixin, WidgetBase): '''Displays a custom variable.''' def __init__( - self, - name, - format='{name}: {formatted_value}', - width=6, - precision=3, - **kwargs, + self, + name, + format='{name}: {formatted_value}', + width=6, + precision=3, + **kwargs, ): '''Creates a Variable associated with the given name.''' self.format = format @@ -1377,10 +1422,10 @@ def __init__( WidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): value = data['variables'][self.name] context = data.copy() @@ -1416,20 +1461,20 @@ class CurrentTime(FormatWidgetMixin, TimeSensitiveWidgetBase): INTERVAL = datetime.timedelta(seconds=1) def __init__( - self, - format='Current Time: %(current_time)s', - microseconds=False, - **kwargs, + self, + format='Current Time: %(current_time)s', + microseconds=False, + **kwargs, ): self.microseconds = microseconds FormatWidgetMixin.__init__(self, format=format, **kwargs) TimeSensitiveWidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): data['current_time'] = self.current_time() data['current_datetime'] = self.current_datetime() @@ -1479,19 +1524,19 @@ class JobStatusBar(Bar, VariableMixin): job_markers: list[str] def __init__( - self, - name: str, - left='|', - right='|', - fill=' ', - fill_left=True, - success_fg_color=colors.green, - success_bg_color=None, - success_marker='█', - failure_fg_color=colors.red, - failure_bg_color=None, - failure_marker='X', - **kwargs, + self, + name: str, + left='|', + right='|', + fill=' ', + fill_left=True, + success_fg_color=colors.green, + success_bg_color=None, + success_marker='█', + failure_fg_color=colors.red, + failure_bg_color=None, + failure_marker='X', + **kwargs, ): VariableMixin.__init__(self, name) self.name = name @@ -1516,11 +1561,11 @@ def __init__( ) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - color=True, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, ): left = converters.to_unicode(self.left(progress, data, width)) right = converters.to_unicode(self.right(progress, data, width)) From a45d669fd2e5bcf6990c7eb1e721bdc74d564434 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 25 Jan 2024 03:49:22 +0100 Subject: [PATCH 03/17] Added smoothing eta to fix #280. The previous algorithm could be really jumpy in some cases and has been replaced with an exponential moving average. Double exponential moving average is also available --- progressbar/algorithms.py | 53 +++++++++++++++++++++++++ tests/test_algorithms.py | 47 +++++++++++++++++++++++ tests/test_monitor_progress.py | 26 ++++++------- tests/test_windows.py | 70 ++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 progressbar/algorithms.py create mode 100644 tests/test_algorithms.py create mode 100644 tests/test_windows.py diff --git a/progressbar/algorithms.py b/progressbar/algorithms.py new file mode 100644 index 0000000..be107e8 --- /dev/null +++ b/progressbar/algorithms.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import abc +from datetime import timedelta + + +class SmoothingAlgorithm(abc.ABC): + + @abc.abstractmethod + def __init__(self, **kwargs): + raise NotImplementedError + + @abc.abstractmethod + def update(self, new_value: float, elapsed: timedelta) -> float: + '''Updates the algorithm with a new value and returns the smoothed + value. + ''' + raise NotImplementedError + + +class ExponentialMovingAverage(SmoothingAlgorithm): + ''' + The Exponential Moving Average (EMA) is an exponentially weighted moving + average that reduces the lag that's typically associated with a simple + moving average. It's more responsive to recent changes in data. + ''' + + def __init__(self, alpha: float = 0.5) -> None: + self.alpha = alpha + self.value = 0 + + def update(self, new_value: float, elapsed: timedelta) -> float: + self.value = self.alpha * new_value + (1 - self.alpha) * self.value + return self.value + + +class DoubleExponentialMovingAverage(SmoothingAlgorithm): + ''' + The Double Exponential Moving Average (DEMA) is essentially an EMA of an + EMA, which reduces the lag that's typically associated with a simple EMA. + It's more responsive to recent changes in data. + ''' + + def __init__(self, alpha: float=0.5) -> None: + self.alpha = alpha + self.ema1 = 0 + self.ema2 = 0 + + def update(self, new_value: float, elapsed: timedelta) -> float: + self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1 + self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2 + return 2 * self.ema1 - self.ema2 + diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py new file mode 100644 index 0000000..e7128d0 --- /dev/null +++ b/tests/test_algorithms.py @@ -0,0 +1,47 @@ +import pytest +from datetime import timedelta + +from progressbar import algorithms + + +def test_ema_initialization(): + ema = algorithms.ExponentialMovingAverage() + assert ema.alpha == 0.5 + assert ema.value == 0 + +@pytest.mark.parametrize('alpha, new_value, expected', [ + (0.5, 10, 5), + (0.1, 20, 2), + (0.9, 30, 27), + (0.3, 15, 4.5), + (0.7, 40, 28), + (0.5, 0, 0), + (0.2, 100, 20), + (0.8, 50, 40) +]) +def test_ema_update(alpha, new_value, expected): + ema = algorithms.ExponentialMovingAverage(alpha) + result = ema.update(new_value, timedelta(seconds=1)) + assert result == expected + +def test_dema_initialization(): + dema = algorithms.DoubleExponentialMovingAverage() + assert dema.alpha == 0.5 + assert dema.ema1 == 0 + assert dema.ema2 == 0 + +@pytest.mark.parametrize('alpha, new_value, expected', [ + (0.5, 10, 7.5), + (0.1, 20, 3.8), + (0.9, 30, 29.7), + (0.3, 15, 7.65), + (0.5, 0, 0), + (0.2, 100, 36.0), + (0.8, 50, 48.0) +]) +def test_dema_update(alpha, new_value, expected): + dema = algorithms.DoubleExponentialMovingAverage(alpha) + result = dema.update(new_value, timedelta(seconds=1)) + assert result == expected + +# Additional test functions can be added here as needed. diff --git a/tests/test_monitor_progress.py b/tests/test_monitor_progress.py index 7105254..66661d4 100644 --- a/tests/test_monitor_progress.py +++ b/tests/test_monitor_progress.py @@ -140,20 +140,18 @@ def test_rapid_updates(testdir): ) result.stderr.lines = _non_empty_lines(result.stderr.lines) pprint.pprint(result.stderr.lines, width=70) - result.stderr.fnmatch_lines( - [ - ' 0% (0 of 10) | | Elapsed Time: ?:00:00 ETA: --:--:--', - ' 10% (1 of 10) | | Elapsed Time: ?:00:01 ETA: ?:00:09', - ' 20% (2 of 10) |# | Elapsed Time: ?:00:02 ETA: ?:00:08', - ' 30% (3 of 10) |# | Elapsed Time: ?:00:03 ETA: ?:00:07', - ' 40% (4 of 10) |## | Elapsed Time: ?:00:04 ETA: ?:00:06', - ' 50% (5 of 10) |### | Elapsed Time: ?:00:05 ETA: ?:00:05', - ' 60% (6 of 10) |### | Elapsed Time: ?:00:07 ETA: ?:00:06', - ' 70% (7 of 10) |#### | Elapsed Time: ?:00:09 ETA: ?:00:06', - ' 80% (8 of 10) |#### | Elapsed Time: ?:00:11 ETA: ?:00:04', - ' 90% (9 of 10) |##### | Elapsed Time: ?:00:13 ETA: ?:00:02', - '100% (10 of 10) |#####| Elapsed Time: ?:00:15 Time: ?:00:15', - ], + result.stderr.fnmatch_lines([' 0% (0 of 10) | | Elapsed Time: 0:00:00 ETA: --:--:--', + ' 10% (1 of 10) | | Elapsed Time: 0:00:01 ETA: 0:00:09', + ' 20% (2 of 10) |# | Elapsed Time: 0:00:02 ETA: 0:00:08', + ' 30% (3 of 10) |# | Elapsed Time: 0:00:03 ETA: 0:00:07', + ' 40% (4 of 10) |## | Elapsed Time: 0:00:04 ETA: 0:00:06', + ' 50% (5 of 10) |### | Elapsed Time: 0:00:05 ETA: 0:00:05', + ' 60% (6 of 10) |### | Elapsed Time: 0:00:07 ETA: 0:00:04', + ' 70% (7 of 10) |#### | Elapsed Time: 0:00:09 ETA: 0:00:03', + ' 80% (8 of 10) |#### | Elapsed Time: 0:00:11 ETA: 0:00:02', + ' 90% (9 of 10) |##### | Elapsed Time: 0:00:13 ETA: 0:00:01', + '100% (10 of 10) |#####| Elapsed Time: 0:00:15 Time: 0:00:15', + ] ) diff --git a/tests/test_windows.py b/tests/test_windows.py new file mode 100644 index 0000000..12a32bf --- /dev/null +++ b/tests/test_windows.py @@ -0,0 +1,70 @@ +import time +import sys +import pytest + +if sys.platform.startswith('win'): + import win32console # "pip install pypiwin32" to get this +else: + pytest.skip('skipping windows-only tests', allow_module_level=True) + + +import progressbar + +_WIDGETS = [progressbar.Percentage(), ' ', + progressbar.Bar(), ' ', + progressbar.FileTransferSpeed(), ' ', + progressbar.ETA()] +_MB = 1024 * 1024 + + +# --------------------------------------------------------------------------- +def scrape_console(line_count): + pcsb = win32console.GetStdHandle(win32console.STD_OUTPUT_HANDLE) + csbi = pcsb.GetConsoleScreenBufferInfo() + col_max = csbi['Size'].X + row_max = csbi['CursorPosition'].Y + + line_count = min(line_count, row_max) + lines = [] + for row in range(line_count): + pct = win32console.PyCOORDType(0, row + row_max - line_count) + line = pcsb.ReadConsoleOutputCharacter(col_max, pct) + lines.append(line.rstrip()) + return lines + + +# --------------------------------------------------------------------------- +def runprogress(): + print('***BEGIN***') + b = progressbar.ProgressBar(widgets=['example.m4v: '] + _WIDGETS, + max_value=10 * _MB) + for i in range(10): + b.update((i + 1) * _MB) + time.sleep(0.25) + b.finish() + print('***END***') + return 0 + + +# --------------------------------------------------------------------------- +def find(L, x): + try: + return L.index(x) + except ValueError: + return -sys.maxsize + + +# --------------------------------------------------------------------------- +def test_windows(argv): + runprogress() + + scraped_lines = scrape_console(100) + scraped_lines.reverse() # reverse lines so we find the LAST instances of output. + index_begin = find(scraped_lines, '***BEGIN***') + index_end = find(scraped_lines, '***END***') + + if index_end + 2 != index_begin: + print('ERROR: Unexpected multi-line output from progressbar') + print(f'{index_begin=} {index_end=}') + return 1 + return 0 From 320bb54a82de1d991b4fe59d2526d79f537628af Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 25 Jan 2024 03:49:22 +0100 Subject: [PATCH 04/17] ttempting to get windows working and tested --- pyproject.toml | 1 + tests/test_windows.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c4c2a95..04e8fcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,7 @@ tests = [ 'pytest-mypy', 'pytest>=4.6.9', 'sphinx>=1.8.5', + 'pywin32; sys_platform == "win32"', ] [project.urls] diff --git a/tests/test_windows.py b/tests/test_windows.py index 12a32bf..48e7c54 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -55,7 +55,7 @@ def find(L, x): # --------------------------------------------------------------------------- -def test_windows(argv): +def test_windows(): runprogress() scraped_lines = scrape_console(100) From a9c677021e963cb419d0eac764ada0ee2f6add79 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 25 Jan 2024 03:49:22 +0100 Subject: [PATCH 05/17] Greatly improved windows color support and fixed #291 --- progressbar/env.py | 43 +++++-- progressbar/terminal/base.py | 121 +++++++++++++++---- progressbar/terminal/os_specific/__init__.py | 5 + progressbar/terminal/os_specific/windows.py | 61 +++++++--- 4 files changed, 184 insertions(+), 46 deletions(-) diff --git a/progressbar/env.py b/progressbar/env.py index 07e6666..a638090 100644 --- a/progressbar/env.py +++ b/progressbar/env.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import enum import os import re @@ -8,6 +9,7 @@ from . import base + @typing.overload def env_flag(name: str, default: bool) -> bool: ... @@ -41,6 +43,7 @@ class ColorSupport(enum.IntEnum): XTERM = 16 XTERM_256 = 256 XTERM_TRUECOLOR = 16777216 + WINDOWS = 8 @classmethod def from_env(cls): @@ -65,10 +68,22 @@ def from_env(cls): ) if os.environ.get('JUPYTER_COLUMNS') or os.environ.get( - 'JUPYTER_LINES', + 'JUPYTER_LINES', ): # Jupyter notebook always supports true color. return cls.XTERM_TRUECOLOR + elif os.name == 'nt': + # We can't reliably detect true color support on Windows, so we + # will assume it is supported if the console is configured to + # support it. + from .terminal.os_specific import windows + if ( + windows.get_console_mode() & + windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + ): + return cls.XTERM_TRUECOLOR + else: + return cls.WINDOWS support = cls.NONE for variable in variables: @@ -88,9 +103,9 @@ def from_env(cls): def is_ansi_terminal( - fd: base.IO, - is_terminal: bool | None = None, -) -> bool: # pragma: no cover + fd: base.IO, + is_terminal: bool | None = None, +) -> bool | None: # pragma: no cover if is_terminal is None: # Jupyter Notebooks define this variable and support progress bars if 'JPY_PARENT_PID' in os.environ: @@ -98,7 +113,7 @@ def is_ansi_terminal( # This works for newer versions of pycharm only. With older versions # there is no way to check. elif os.environ.get('PYCHARM_HOSTED') == '1' and not os.environ.get( - 'PYTEST_CURRENT_TEST', + 'PYTEST_CURRENT_TEST', ): is_terminal = True @@ -108,7 +123,7 @@ def is_ansi_terminal( # isatty has not been defined we have no way of knowing so we will not # use ansi. ansi terminals will typically define one of the 2 # environment variables. - try: + with contextlib.suppress(Exception): is_tty = fd.isatty() # Try and match any of the huge amount of Linux/Unix ANSI consoles if is_tty and ANSI_TERM_RE.match(os.environ.get('TERM', '')): @@ -116,12 +131,16 @@ def is_ansi_terminal( # ANSICON is a Windows ANSI compatible console elif 'ANSICON' in os.environ: is_terminal = True + elif os.name == 'nt': + from .terminal.os_specific import windows + return bool( + windows.get_console_mode() & + windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + ) else: is_terminal = None - except Exception: - is_terminal = False - return bool(is_terminal) + return is_terminal def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool: @@ -144,6 +163,12 @@ def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool: return bool(is_terminal) +# Enable Windows full color mode if possible +if os.name == 'nt': + from .terminal import os_specific + + os_specific.set_console_mode() + COLOR_SUPPORT = ColorSupport.from_env() ANSI_TERMS = ( '([xe]|bv)term', diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index 8c9b262..b8d2a97 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -3,20 +3,20 @@ import abc import collections import colorsys +import enum import threading from collections import defaultdict - # Ruff is being stupid and doesn't understand `ClassVar` if it comes from the # `types` module from typing import ClassVar from python_utils import converters, types +from .os_specific import getch from .. import ( base as pbase, env, ) -from .os_specific import getch ESC = '\x1B' @@ -178,6 +178,53 @@ def column(self, stream): return column + +class WindowsColors(enum.Enum): + BLACK = 0, 0, 0 + BLUE = 0, 0, 128 + GREEN = 0, 128, 0 + CYAN = 0, 128, 128 + RED = 128, 0, 0 + MAGENTA = 128, 0, 128 + YELLOW = 128, 128, 0 + GREY = 192, 192, 192 + INTENSE_BLACK = 128, 128, 128 + INTENSE_BLUE = 0, 0, 255 + INTENSE_GREEN = 0, 255, 0 + INTENSE_CYAN = 0, 255, 255 + INTENSE_RED = 255, 0, 0 + INTENSE_MAGENTA = 255, 0, 255 + INTENSE_YELLOW = 255, 255, 0 + INTENSE_WHITE = 255, 255, 255 + + @staticmethod + def from_rgb(rgb: types.Tuple[int, int, int]): + """Find the closest ConsoleColor to the given RGB color.""" + + def color_distance(rgb1, rgb2): + return sum((c1 - c2) ** 2 for c1, c2 in zip(rgb1, rgb2)) + + return min( + WindowsColors, + key=lambda color: color_distance(color.value, rgb), + ) + + +class WindowsColor: + __slots__ = 'color', + + def __init__(self, color: Color): + self.color = color + + def __call__(self, text): + return text + # In the future we might want to use this, but it requires direct printing to stdout and all of our surrounding functions expect buffered output so it's not feasible right now. + # Additionally, recent Windows versions all support ANSI codes without issue so there is little need. + # from progressbar.terminal.os_specific import windows + # windows.print_color(text, WindowsColors.from_rgb(self.color.rgb)) + + + class RGB(collections.namedtuple('RGB', ['red', 'green', 'blue'])): __slots__ = () @@ -207,6 +254,14 @@ def to_ansi_256(self): blue = round(self.blue / 255 * 5) return 16 + 36 * red + 6 * green + blue + @property + def to_windows(self): + ''' + Convert an RGB color (0-255 per channel) to the closest color in the + Windows 16 color scheme. + ''' + return WindowsColors.from_rgb((self.red, self.green, self.blue)) + def interpolate(self, end: RGB, step: float) -> RGB: return RGB( int(self.red + (end.red - self.red) * step), @@ -286,27 +341,36 @@ def __call__(self, value: str) -> str: @property def fg(self): - return SGRColor(self, 38, 39) + if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS: + return WindowsColor(self) + else: + return SGRColor(self, 38, 39) @property def bg(self): - return SGRColor(self, 48, 49) + if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS: + return DummyColor() + else: + return SGRColor(self, 48, 49) @property def underline(self): - return SGRColor(self, 58, 59) + if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS: + return DummyColor() + else: + return SGRColor(self, 58, 59) @property def ansi(self) -> types.Optional[str]: if ( - env.COLOR_SUPPORT is env.ColorSupport.XTERM_TRUECOLOR + env.COLOR_SUPPORT is env.ColorSupport.XTERM_TRUECOLOR ): # pragma: no branch return f'2;{self.rgb.red};{self.rgb.green};{self.rgb.blue}' if self.xterm: # pragma: no branch color = self.xterm elif ( - env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 + env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 ): # pragma: no branch color = self.rgb.to_ansi_256 elif env.COLOR_SUPPORT is env.ColorSupport.XTERM: # pragma: no branch @@ -354,11 +418,11 @@ class Colors: @classmethod def register( - cls, - rgb: RGB, - hls: types.Optional[HSL] = None, - name: types.Optional[str] = None, - xterm: types.Optional[int] = None, + cls, + rgb: RGB, + hls: types.Optional[HSL] = None, + name: types.Optional[str] = None, + xterm: types.Optional[int] = None, ) -> Color: color = Color(rgb, hls, name, xterm) @@ -395,9 +459,9 @@ def __call__(self, value: float) -> Color: def get_color(self, value: float) -> Color: 'Map a value from 0 to 1 to a color.' if ( - value == pbase.Undefined - or value == pbase.UnknownLength - or value <= 0 + value == pbase.Undefined + or value == pbase.UnknownLength + or value <= 0 ): return self.colors[0] elif value >= 1: @@ -443,14 +507,14 @@ def get_color(value: float, color: OptionalColor) -> Color | None: def apply_colors( - text: str, - percentage: float | None = None, - *, - fg: OptionalColor = None, - bg: OptionalColor = None, - fg_none: Color | None = None, - bg_none: Color | None = None, - **kwargs: types.Any, + text: str, + percentage: float | None = None, + *, + fg: OptionalColor = None, + bg: OptionalColor = None, + fg_none: Color | None = None, + bg_none: Color | None = None, + **kwargs: types.Any, ) -> str: '''Apply colors/gradients to a string depending on the given percentage. @@ -475,6 +539,17 @@ def apply_colors( return text +class DummyColor: + def __call__(self, text): + return text + + def __getattr__(self, item): + return self + + def __repr__(self): + return 'DummyColor()' + + class SGR(CSI): _start_code: int _end_code: int diff --git a/progressbar/terminal/os_specific/__init__.py b/progressbar/terminal/os_specific/__init__.py index 3d27cf5..4dd10ff 100644 --- a/progressbar/terminal/os_specific/__init__.py +++ b/progressbar/terminal/os_specific/__init__.py @@ -5,6 +5,7 @@ getch as _getch, reset_console_mode as _reset_console_mode, set_console_mode as _set_console_mode, + get_console_mode as _get_console_mode, ) else: @@ -16,7 +17,11 @@ def _reset_console_mode(): def _set_console_mode(): pass + def _get_console_mode(): + return 0 + getch = _getch reset_console_mode = _reset_console_mode set_console_mode = _set_console_mode +get_console_mode = _get_console_mode diff --git a/progressbar/terminal/os_specific/windows.py b/progressbar/terminal/os_specific/windows.py index f294845..f23f41f 100644 --- a/progressbar/terminal/os_specific/windows.py +++ b/progressbar/terminal/os_specific/windows.py @@ -5,7 +5,10 @@ Note that the naming convention here is non-pythonic because we are matching the Windows API naming. ''' +from __future__ import annotations + import ctypes +import enum from ctypes.wintypes import ( BOOL as _BOOL, CHAR as _CHAR, @@ -19,14 +22,31 @@ _kernel32 = ctypes.windll.Kernel32 # type: ignore -_ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 -_ENABLE_PROCESSED_OUTPUT = 0x0001 -_ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 - _STD_INPUT_HANDLE = _DWORD(-10) _STD_OUTPUT_HANDLE = _DWORD(-11) +class WindowsConsoleModeFlags(enum.IntFlag): + ENABLE_ECHO_INPUT = 0x0004 + ENABLE_EXTENDED_FLAGS = 0x0080 + ENABLE_INSERT_MODE = 0x0020 + ENABLE_LINE_INPUT = 0x0002 + ENABLE_MOUSE_INPUT = 0x0010 + ENABLE_PROCESSED_INPUT = 0x0001 + ENABLE_QUICK_EDIT_MODE = 0x0040 + ENABLE_WINDOW_INPUT = 0x0008 + ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 + + ENABLE_PROCESSED_OUTPUT = 0x0001 + ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + DISABLE_NEWLINE_AUTO_RETURN = 0x0008 + ENABLE_LVB_GRID_WORLDWIDE = 0x0010 + + def __str__(self): + return f'{self.name} (0x{self.value:04X})' + + _GetConsoleMode = _kernel32.GetConsoleMode _GetConsoleMode.restype = _BOOL @@ -39,7 +59,6 @@ _ReadConsoleInput = _kernel32.ReadConsoleInputA _ReadConsoleInput.restype = _BOOL - _h_console_input = _GetStdHandle(_STD_INPUT_HANDLE) _input_mode = _DWORD() _GetConsoleMode(_HANDLE(_h_console_input), ctypes.byref(_input_mode)) @@ -54,7 +73,7 @@ class _COORD(ctypes.Structure): class _FOCUS_EVENT_RECORD(ctypes.Structure): - _fields_ = (('bSetFocus', _BOOL), ) + _fields_ = (('bSetFocus', _BOOL),) class _KEY_EVENT_RECORD(ctypes.Structure): @@ -72,7 +91,7 @@ class _uchar(ctypes.Union): class _MENU_EVENT_RECORD(ctypes.Structure): - _fields_ = (('dwCommandId', _UINT), ) + _fields_ = (('dwCommandId', _UINT),) class _MOUSE_EVENT_RECORD(ctypes.Structure): @@ -85,7 +104,7 @@ class _MOUSE_EVENT_RECORD(ctypes.Structure): class _WINDOW_BUFFER_SIZE_RECORD(ctypes.Structure): - _fields_ = (('dwSize', _COORD), ) + _fields_ = (('dwSize', _COORD),) class _INPUT_RECORD(ctypes.Structure): @@ -106,16 +125,30 @@ def reset_console_mode(): _SetConsoleMode(_HANDLE(_h_console_output), _DWORD(_output_mode.value)) -def set_console_mode(): - mode = _input_mode.value | _ENABLE_VIRTUAL_TERMINAL_INPUT +def set_console_mode() -> bool: + mode = _input_mode.value | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_INPUT _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(mode)) mode = ( - _output_mode.value - | _ENABLE_PROCESSED_OUTPUT - | _ENABLE_VIRTUAL_TERMINAL_PROCESSING + _output_mode.value + | WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_PROCESSING ) - _SetConsoleMode(_HANDLE(_h_console_output), _DWORD(mode)) + return bool(_SetConsoleMode(_HANDLE(_h_console_output), _DWORD(mode))) + + +def get_console_mode() -> int: + return _input_mode.value + + +def set_text_color(color): + _kernel32.SetConsoleTextAttribute(_h_console_output, color) + + +def print_color(text, color): + set_text_color(color) + print(text) + set_text_color(7) # Reset to default color, grey def getch(): From 9a16b73f48320422de3115a2e3532e614cfc69f1 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 25 Jan 2024 03:49:22 +0100 Subject: [PATCH 06/17] Fixing windows test issues --- tests/test_color.py | 8 +++++--- tests/test_stream.py | 2 ++ tests/test_utils.py | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_color.py b/tests/test_color.py index 1a6657e..bf76eec 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import typing import progressbar @@ -183,9 +184,10 @@ def test_colors(): def test_color(): color = colors.red - assert color('x') == color.fg('x') != 'x' - assert color.fg('x') != color.bg('x') != 'x' - assert color.fg('x') != color.underline('x') != 'x' + if os.name != 'nt': + assert color('x') == color.fg('x') != 'x' + assert color.fg('x') != color.bg('x') != 'x' + assert color.fg('x') != color.underline('x') != 'x' # Color hashes are based on the RGB value assert hash(color) == hash(terminal.Color(color.rgb, None, None, None)) Colors.register(color.rgb) diff --git a/tests/test_stream.py b/tests/test_stream.py index c92edf7..1803ffd 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -1,4 +1,5 @@ import io +import os import sys import progressbar @@ -98,6 +99,7 @@ def test_no_newlines(): @pytest.mark.parametrize('stream', [sys.__stdout__, sys.__stderr__]) +@pytest.mark.skipif(os.name == 'nt', reason='Windows does not support this') def test_fd_as_standard_streams(stream): with progressbar.ProgressBar(fd=stream) as pb: for i in range(101): diff --git a/tests/test_utils.py b/tests/test_utils.py index 34bd0da..c9d9531 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import io +import os import progressbar import progressbar.env @@ -107,4 +108,7 @@ def test_is_ansi_terminal(monkeypatch): def raise_error(): raise RuntimeError('test') fd.isatty = raise_error - assert progressbar.env.is_ansi_terminal(fd) is False + if os.name == 'nt': + assert progressbar.env.is_ansi_terminal(fd) is None + else: + assert progressbar.env.is_ansi_terminal(fd) is False From 16c4fcef97882c4fbecefe66516a05e6b80696cf Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 25 Jan 2024 10:16:55 +0100 Subject: [PATCH 07/17] Fixed tests and test coverage --- progressbar/bar.py | 6 ++---- progressbar/terminal/base.py | 29 +++++++++++++++++++++++++---- pyproject.toml | 1 + tests/test_color.py | 17 +++++++++++++++++ tests/test_utils.py | 17 +++++++---------- 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/progressbar/bar.py b/progressbar/bar.py index 01620f9..4cfc835 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -256,10 +256,8 @@ def _determine_enable_colors( else: enable_colors = progressbar.env.ColorSupport.NONE break - else: # pragma: no cover - # This scenario should never occur because `is_ansi_terminal` - # should always be `True` or `False` - raise ValueError('Unable to determine color support') + else: + enable_colors = False elif enable_colors is True: enable_colors = progressbar.env.ColorSupport.XTERM_256 diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index b8d2a97..85971c5 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -199,7 +199,24 @@ class WindowsColors(enum.Enum): @staticmethod def from_rgb(rgb: types.Tuple[int, int, int]): - """Find the closest ConsoleColor to the given RGB color.""" + ''' + Find the closest WindowsColors to the given RGB color. + + >>> WindowsColors.from_rgb((0, 0, 0)) + + + >>> WindowsColors.from_rgb((255, 255, 255)) + + + >>> WindowsColors.from_rgb((0, 255, 0)) + + + >>> WindowsColors.from_rgb((45, 45, 45)) + + + >>> WindowsColors.from_rgb((128, 0, 128)) + + ''' def color_distance(rgb1, rgb2): return sum((c1 - c2) ** 2 for c1, c2 in zip(rgb1, rgb2)) @@ -211,6 +228,13 @@ def color_distance(rgb1, rgb2): class WindowsColor: + ''' + Windows compatible color class for when ANSI is not supported. + Currently a no-op because it is not possible to buffer these colors. + + >>> WindowsColor(WindowsColors.RED)('test') + 'test' + ''' __slots__ = 'color', def __init__(self, color: Color): @@ -543,9 +567,6 @@ class DummyColor: def __call__(self, text): return text - def __getattr__(self, item): - return self - def __repr__(self): return 'DummyColor()' diff --git a/pyproject.toml b/pyproject.toml index 04e8fcd..a6d553d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -181,6 +181,7 @@ exclude_lines = [ 'if __name__ == .__main__.:', 'if types.TYPE_CHECKING:', '@typing.overload', + 'if os.name == .nt.:', ] [tool.pyright] diff --git a/tests/test_color.py b/tests/test_color.py index bf76eec..feb962e 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -175,6 +175,7 @@ def test_colors(): assert rgb.hex assert rgb.to_ansi_16 is not None assert rgb.to_ansi_256 is not None + assert rgb.to_windows is not None assert color.underline assert color.fg assert color.bg @@ -304,6 +305,22 @@ def test_apply_colors(text, fg, bg, fg_none, bg_none, percentage, expected, ) +def test_windows_colors(monkeypatch): + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.WINDOWS) + assert ( + apply_colors( + 'test', + fg=colors.red, + bg=colors.red, + fg_none=colors.red, + bg_none=colors.red, + percentage=1, + ) + == 'test' + ) + colors.red.underline('test') + + def test_ansi_color(monkeypatch): color = progressbar.terminal.Color( colors.red.rgb, diff --git a/tests/test_utils.py b/tests/test_utils.py index c9d9531..2f03062 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -68,7 +68,7 @@ def test_is_ansi_terminal(monkeypatch): monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL', raising=False) monkeypatch.delenv('JPY_PARENT_PID', raising=False) - assert progressbar.env.is_ansi_terminal(fd) is False + assert not progressbar.env.is_ansi_terminal(fd) assert progressbar.env.is_ansi_terminal(fd, True) is True assert progressbar.env.is_ansi_terminal(fd, False) is False @@ -77,16 +77,16 @@ def test_is_ansi_terminal(monkeypatch): monkeypatch.delenv('JPY_PARENT_PID') # Sanity check - assert progressbar.env.is_ansi_terminal(fd) is False + assert not progressbar.env.is_ansi_terminal(fd) monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'true') - assert progressbar.env.is_ansi_terminal(fd) is False + assert not progressbar.env.is_ansi_terminal(fd) monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'false') - assert progressbar.env.is_ansi_terminal(fd) is False + assert not progressbar.env.is_ansi_terminal(fd) monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL') # Sanity check - assert progressbar.env.is_ansi_terminal(fd) is False + assert not progressbar.env.is_ansi_terminal(fd) # Fake TTY mode for environment testing fd.isatty = lambda: True @@ -103,12 +103,9 @@ def test_is_ansi_terminal(monkeypatch): monkeypatch.setenv('ANSICON', 'true') assert progressbar.env.is_ansi_terminal(fd) is True monkeypatch.delenv('ANSICON') - assert progressbar.env.is_ansi_terminal(fd) is False + assert not progressbar.env.is_ansi_terminal(fd) def raise_error(): raise RuntimeError('test') fd.isatty = raise_error - if os.name == 'nt': - assert progressbar.env.is_ansi_terminal(fd) is None - else: - assert progressbar.env.is_ansi_terminal(fd) is False + assert not progressbar.env.is_ansi_terminal(fd) From 671b723da4c9266304b11b263408efc253176a82 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 25 Jan 2024 10:38:15 +0100 Subject: [PATCH 08/17] fixed ruff issues --- progressbar/__init__.py | 9 +- progressbar/algorithms.py | 4 +- progressbar/env.py | 19 +- progressbar/terminal/base.py | 51 +- progressbar/terminal/os_specific/__init__.py | 2 +- progressbar/terminal/os_specific/windows.py | 13 +- progressbar/widgets.py | 511 ++++++++++--------- tests/test_algorithms.py | 6 +- tests/test_monitor_progress.py | 5 +- tests/test_utils.py | 1 - tests/test_windows.py | 16 +- 11 files changed, 329 insertions(+), 308 deletions(-) diff --git a/progressbar/__init__.py b/progressbar/__init__.py index 7da3977..ff76ff4 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -1,18 +1,21 @@ from datetime import date from .__about__ import __author__, __version__ +from .algorithms import ( + DoubleExponentialMovingAverage, + ExponentialMovingAverage, + SmoothingAlgorithm, +) from .bar import DataTransferBar, NullBar, ProgressBar from .base import UnknownLength from .multi import MultiBar, SortKey from .shortcuts import progressbar from .terminal.stream import LineOffsetStreamWrapper from .utils import len_color, streams -from .algorithms import ExponentialMovingAverage, SmoothingAlgorithm, DoubleExponentialMovingAverage from .widgets import ( ETA, AbsoluteETA, AdaptiveETA, - SmoothingETA, AdaptiveTransferSpeed, AnimatedMarker, Bar, @@ -34,11 +37,11 @@ ReverseBar, RotatingMarker, SimpleProgress, + SmoothingETA, Timer, Variable, VariableMixin, ) -from .algorithms import ExponentialMovingAverage, SmoothingAlgorithm __date__ = str(date.today()) __all__ = [ diff --git a/progressbar/algorithms.py b/progressbar/algorithms.py index be107e8..bb8586e 100644 --- a/progressbar/algorithms.py +++ b/progressbar/algorithms.py @@ -5,7 +5,6 @@ class SmoothingAlgorithm(abc.ABC): - @abc.abstractmethod def __init__(self, **kwargs): raise NotImplementedError @@ -41,7 +40,7 @@ class DoubleExponentialMovingAverage(SmoothingAlgorithm): It's more responsive to recent changes in data. ''' - def __init__(self, alpha: float=0.5) -> None: + def __init__(self, alpha: float = 0.5) -> None: self.alpha = alpha self.ema1 = 0 self.ema2 = 0 @@ -50,4 +49,3 @@ def update(self, new_value: float, elapsed: timedelta) -> float: self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1 self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2 return 2 * self.ema1 - self.ema2 - diff --git a/progressbar/env.py b/progressbar/env.py index a638090..8a45953 100644 --- a/progressbar/env.py +++ b/progressbar/env.py @@ -9,7 +9,6 @@ from . import base - @typing.overload def env_flag(name: str, default: bool) -> bool: ... @@ -68,7 +67,7 @@ def from_env(cls): ) if os.environ.get('JUPYTER_COLUMNS') or os.environ.get( - 'JUPYTER_LINES', + 'JUPYTER_LINES', ): # Jupyter notebook always supports true color. return cls.XTERM_TRUECOLOR @@ -77,9 +76,10 @@ def from_env(cls): # will assume it is supported if the console is configured to # support it. from .terminal.os_specific import windows + if ( - windows.get_console_mode() & - windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + windows.get_console_mode() + & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT ): return cls.XTERM_TRUECOLOR else: @@ -103,8 +103,8 @@ def from_env(cls): def is_ansi_terminal( - fd: base.IO, - is_terminal: bool | None = None, + fd: base.IO, + is_terminal: bool | None = None, ) -> bool | None: # pragma: no cover if is_terminal is None: # Jupyter Notebooks define this variable and support progress bars @@ -113,7 +113,7 @@ def is_ansi_terminal( # This works for newer versions of pycharm only. With older versions # there is no way to check. elif os.environ.get('PYCHARM_HOSTED') == '1' and not os.environ.get( - 'PYTEST_CURRENT_TEST', + 'PYTEST_CURRENT_TEST', ): is_terminal = True @@ -133,9 +133,10 @@ def is_ansi_terminal( is_terminal = True elif os.name == 'nt': from .terminal.os_specific import windows + return bool( - windows.get_console_mode() & - windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + windows.get_console_mode() + & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT, ) else: is_terminal = None diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index 85971c5..55c031e 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -6,17 +6,18 @@ import enum import threading from collections import defaultdict + # Ruff is being stupid and doesn't understand `ClassVar` if it comes from the # `types` module from typing import ClassVar from python_utils import converters, types -from .os_specific import getch from .. import ( base as pbase, env, ) +from .os_specific import getch ESC = '\x1B' @@ -178,7 +179,6 @@ def column(self, stream): return column - class WindowsColors(enum.Enum): BLACK = 0, 0, 0 BLUE = 0, 0, 128 @@ -235,20 +235,23 @@ class WindowsColor: >>> WindowsColor(WindowsColors.RED)('test') 'test' ''' - __slots__ = 'color', + + __slots__ = ('color',) def __init__(self, color: Color): self.color = color def __call__(self, text): return text - # In the future we might want to use this, but it requires direct printing to stdout and all of our surrounding functions expect buffered output so it's not feasible right now. - # Additionally, recent Windows versions all support ANSI codes without issue so there is little need. + ## In the future we might want to use this, but it requires direct + ## printing to stdout and all of our surrounding functions expect + ## buffered output so it's not feasible right now. Additionally, + ## recent Windows versions all support ANSI codes without issue so + ## there is little need. # from progressbar.terminal.os_specific import windows # windows.print_color(text, WindowsColors.from_rgb(self.color.rgb)) - class RGB(collections.namedtuple('RGB', ['red', 'green', 'blue'])): __slots__ = () @@ -387,14 +390,14 @@ def underline(self): @property def ansi(self) -> types.Optional[str]: if ( - env.COLOR_SUPPORT is env.ColorSupport.XTERM_TRUECOLOR + env.COLOR_SUPPORT is env.ColorSupport.XTERM_TRUECOLOR ): # pragma: no branch return f'2;{self.rgb.red};{self.rgb.green};{self.rgb.blue}' if self.xterm: # pragma: no branch color = self.xterm elif ( - env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 + env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 ): # pragma: no branch color = self.rgb.to_ansi_256 elif env.COLOR_SUPPORT is env.ColorSupport.XTERM: # pragma: no branch @@ -442,11 +445,11 @@ class Colors: @classmethod def register( - cls, - rgb: RGB, - hls: types.Optional[HSL] = None, - name: types.Optional[str] = None, - xterm: types.Optional[int] = None, + cls, + rgb: RGB, + hls: types.Optional[HSL] = None, + name: types.Optional[str] = None, + xterm: types.Optional[int] = None, ) -> Color: color = Color(rgb, hls, name, xterm) @@ -483,9 +486,9 @@ def __call__(self, value: float) -> Color: def get_color(self, value: float) -> Color: 'Map a value from 0 to 1 to a color.' if ( - value == pbase.Undefined - or value == pbase.UnknownLength - or value <= 0 + value == pbase.Undefined + or value == pbase.UnknownLength + or value <= 0 ): return self.colors[0] elif value >= 1: @@ -531,14 +534,14 @@ def get_color(value: float, color: OptionalColor) -> Color | None: def apply_colors( - text: str, - percentage: float | None = None, - *, - fg: OptionalColor = None, - bg: OptionalColor = None, - fg_none: Color | None = None, - bg_none: Color | None = None, - **kwargs: types.Any, + text: str, + percentage: float | None = None, + *, + fg: OptionalColor = None, + bg: OptionalColor = None, + fg_none: Color | None = None, + bg_none: Color | None = None, + **kwargs: types.Any, ) -> str: '''Apply colors/gradients to a string depending on the given percentage. diff --git a/progressbar/terminal/os_specific/__init__.py b/progressbar/terminal/os_specific/__init__.py index 4dd10ff..08c9a80 100644 --- a/progressbar/terminal/os_specific/__init__.py +++ b/progressbar/terminal/os_specific/__init__.py @@ -2,10 +2,10 @@ if sys.platform.startswith('win'): from .windows import ( + get_console_mode as _get_console_mode, getch as _getch, reset_console_mode as _reset_console_mode, set_console_mode as _set_console_mode, - get_console_mode as _get_console_mode, ) else: diff --git a/progressbar/terminal/os_specific/windows.py b/progressbar/terminal/os_specific/windows.py index f23f41f..05f8b69 100644 --- a/progressbar/terminal/os_specific/windows.py +++ b/progressbar/terminal/os_specific/windows.py @@ -126,13 +126,16 @@ def reset_console_mode(): def set_console_mode() -> bool: - mode = _input_mode.value | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_INPUT + mode = ( + _input_mode.value + | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_INPUT + ) _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(mode)) mode = ( - _output_mode.value - | WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT - | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_PROCESSING + _output_mode.value + | WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_PROCESSING ) return bool(_SetConsoleMode(_HANDLE(_h_console_output), _DWORD(mode))) @@ -147,7 +150,7 @@ def set_text_color(color): def print_color(text, color): set_text_color(color) - print(text) + print(text) # noqa: T201 set_text_color(7) # Reset to default color, grey diff --git a/progressbar/widgets.py b/progressbar/widgets.py index f063bc8..ed6e68e 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -6,13 +6,14 @@ import functools import logging import typing + # Ruff is being stupid and doesn't understand `ClassVar` if it comes from the # `types` module from typing import ClassVar from python_utils import containers, converters, types -from . import base, terminal, utils, algorithms +from . import algorithms, base, terminal, utils from .terminal import colors if types.TYPE_CHECKING: @@ -88,8 +89,8 @@ def wrap(*args, **kwargs): def create_marker(marker, wrap=None): def _marker(progress, data, width): if ( - progress.max_value is not base.UnknownLength - and progress.max_value > 0 + progress.max_value is not base.UnknownLength + and progress.max_value > 0 ): length = int(progress.value / progress.max_value * width) return marker * length @@ -99,7 +100,7 @@ def _marker(progress, data, width): if isinstance(marker, str): marker = converters.to_unicode(marker) assert ( - utils.len_color(marker) == 1 + utils.len_color(marker) == 1 ), 'Markers are required to be 1 char' return wrapper(_marker, wrap) else: @@ -127,18 +128,18 @@ def __init__(self, format: str, new_style: bool = False, **kwargs): self.format = format def get_format( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ) -> str: return format or self.format def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ) -> str: '''Formats the widget into a string.''' format_ = self.get_format(progress, data, format) @@ -277,11 +278,11 @@ def _apply_colors(self, text: str, data: Data) -> str: return text def __init__( - self, - *args, - fixed_colors=None, - gradient_colors=None, - **kwargs, + self, + *args, + fixed_colors=None, + gradient_colors=None, + **kwargs, ): if fixed_colors is not None: self._fixed_colors.update(fixed_colors) @@ -305,10 +306,10 @@ class AutoWidthWidgetBase(WidgetBase, metaclass=abc.ABCMeta): @abc.abstractmethod def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, ) -> str: '''Updates the widget providing the total width the widget must fill. @@ -354,10 +355,10 @@ def __init__(self, format: str, **kwargs): WidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): for name, (key, transform) in self.mapping.items(): with contextlib.suppress(KeyError, ValueError, IndexError): @@ -416,10 +417,10 @@ class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta): ''' def __init__( - self, - samples=datetime.timedelta(seconds=2), - key_prefix=None, - **kwargs, + self, + samples=datetime.timedelta(seconds=2), + key_prefix=None, + **kwargs, ): self.samples = samples self.key_prefix = (key_prefix or self.__class__.__name__) + '_' @@ -438,10 +439,10 @@ def get_sample_values(self, progress: ProgressBarMixinBase, data: Data): ) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - delta: bool = False, + self, + progress: ProgressBarMixinBase, + data: Data, + delta: bool = False, ): sample_times = self.get_sample_times(progress, data) sample_values = self.get_sample_values(progress, data) @@ -460,9 +461,9 @@ def __call__( minimum_time = progress.last_update_time - self.samples minimum_value = sample_values[-1] while ( - sample_times[2:] - and minimum_time > sample_times[1] - and minimum_value > sample_values[1] + sample_times[2:] + and minimum_time > sample_times[1] + and minimum_value > sample_values[1] ): sample_times.pop(0) sample_values.pop(0) @@ -484,13 +485,13 @@ class ETA(Timer): '''WidgetBase which attempts to estimate the time of arrival.''' def __init__( - self, - format_not_started='ETA: --:--:--', - format_finished='Time: %(elapsed)8s', - format='ETA: %(eta)8s', - format_zero='ETA: 00:00:00', - format_na='ETA: N/A', - **kwargs, + self, + format_not_started='ETA: --:--:--', + format_finished='Time: %(elapsed)8s', + format='ETA: %(eta)8s', + format_zero='ETA: 00:00:00', + format_na='ETA: N/A', + **kwargs, ): if '%s' in format and '%(eta)s' not in format: format = format.replace('%s', '%(eta)s') @@ -503,11 +504,11 @@ def __init__( self.format_NA = format_na def _calculate_eta( - self, - progress: ProgressBarMixinBase, - data: Data, - value, - elapsed, + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, ): '''Updates the widget to show the ETA or total time when finished.''' if elapsed: @@ -519,11 +520,11 @@ def _calculate_eta( return 0 def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - value=None, - elapsed=None, + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, ): '''Updates the widget to show the ETA or total time when finished.''' if value is None: @@ -567,11 +568,11 @@ class AbsoluteETA(ETA): '''Widget which attempts to estimate the absolute time of arrival.''' def _calculate_eta( - self, - progress: ProgressBarMixinBase, - data: Data, - value, - elapsed, + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, ): eta_seconds = ETA._calculate_eta(self, progress, data, value, elapsed) now = datetime.datetime.now() @@ -581,11 +582,11 @@ def _calculate_eta( return datetime.datetime.max def __init__( - self, - format_not_started='Estimated finish time: ----/--/-- --:--:--', - format_finished='Finished at: %(elapsed)s', - format='Estimated finish time: %(eta)s', - **kwargs, + self, + format_not_started='Estimated finish time: ----/--/-- --:--:--', + format_finished='Finished at: %(elapsed)s', + format='Estimated finish time: %(eta)s', + **kwargs, ): ETA.__init__( self, @@ -602,24 +603,27 @@ class AdaptiveETA(ETA, SamplesMixin): Uses a sampled average of the speed based on the 10 last updates. Very convenient for resuming the progress halfway. ''' + exponential_smoothing: bool exponential_smoothing_factor: float - def __init__(self, - exponential_smoothing=True, - exponential_smoothing_factor=0.1, - **kwargs): + def __init__( + self, + exponential_smoothing=True, + exponential_smoothing_factor=0.1, + **kwargs, + ): self.exponential_smoothing = exponential_smoothing self.exponential_smoothing_factor = exponential_smoothing_factor ETA.__init__(self, **kwargs) SamplesMixin.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - value=None, - elapsed=None, + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, ): elapsed, value = SamplesMixin.__call__( self, @@ -643,25 +647,30 @@ class SmoothingETA(ETA): and doesn't require storing all past values. This approach works well with varying data points and smooths out fluctuations effectively. ''' + smoothing_algorithm: algorithms.SmoothingAlgorithm smoothing_parameters: dict[str, float] - def __init__(self, - smoothing_algorithm: type[algorithms.SmoothingAlgorithm]= - algorithms.ExponentialMovingAverage, - smoothing_parameters: dict[str, float] | None = None, - **kwargs): + def __init__( + self, + smoothing_algorithm: type[ + algorithms.SmoothingAlgorithm + ] = algorithms.ExponentialMovingAverage, + smoothing_parameters: dict[str, float] | None = None, + **kwargs, + ): self.smoothing_parameters = smoothing_parameters or {} self.smoothing_algorithm = smoothing_algorithm( - **(self.smoothing_parameters or {})) + **(self.smoothing_parameters or {}), + ) ETA.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - value=None, - elapsed=None, + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, ): if value is None: # pragma: no branch value = data['value'] @@ -682,12 +691,12 @@ class DataSize(FormatWidgetMixin, WidgetBase): ''' def __init__( - self, - variable='value', - format='%(scaled)5.1f %(prefix)s%(unit)s', - unit='B', - prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), - **kwargs, + self, + variable='value', + format='%(scaled)5.1f %(prefix)s%(unit)s', + unit='B', + prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), + **kwargs, ): self.variable = variable self.unit = unit @@ -696,10 +705,10 @@ def __init__( WidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): value = data[self.variable] if value is not None: @@ -720,12 +729,12 @@ class FileTransferSpeed(FormatWidgetMixin, TimeSensitiveWidgetBase): ''' def __init__( - self, - format='%(scaled)5.1f %(prefix)s%(unit)-s/s', - inverse_format='%(scaled)5.1f s/%(prefix)s%(unit)-s', - unit='B', - prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), - **kwargs, + self, + format='%(scaled)5.1f %(prefix)s%(unit)-s/s', + inverse_format='%(scaled)5.1f s/%(prefix)s%(unit)-s', + unit='B', + prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), + **kwargs, ): self.unit = unit self.prefixes = prefixes @@ -738,11 +747,11 @@ def _speed(self, value, elapsed): return utils.scale_1024(speed, len(self.prefixes)) def __call__( - self, - progress: ProgressBarMixinBase, - data, - value=None, - total_seconds_elapsed=None, + self, + progress: ProgressBarMixinBase, + data, + value=None, + total_seconds_elapsed=None, ): '''Updates the widget with the current SI prefixed speed.''' if value is None: @@ -754,10 +763,10 @@ def __call__( ) if ( - value is not None - and elapsed is not None - and elapsed > 2e-6 - and value > 2e-6 + value is not None + and elapsed is not None + and elapsed > 2e-6 + and value > 2e-6 ): # =~ 0 scaled, power = self._speed(value, elapsed) else: @@ -789,11 +798,11 @@ def __init__(self, **kwargs): SamplesMixin.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data, - value=None, - total_seconds_elapsed=None, + self, + progress: ProgressBarMixinBase, + data, + value=None, + total_seconds_elapsed=None, ): elapsed, value = SamplesMixin.__call__( self, @@ -810,13 +819,13 @@ class AnimatedMarker(TimeSensitiveWidgetBase): ''' def __init__( - self, - markers='|/-\\', - default=None, - fill='', - marker_wrap=None, - fill_wrap=None, - **kwargs, + self, + markers='|/-\\', + default=None, + fill='', + marker_wrap=None, + fill_wrap=None, + **kwargs, ): self.markers = markers self.marker_wrap = create_wrapper(marker_wrap) @@ -869,10 +878,10 @@ def __init__(self, format='%(value)d', **kwargs): WidgetBase.__init__(self, format=format, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format=None, + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, ): return FormatWidgetMixin.__call__(self, progress, data, format) @@ -902,10 +911,10 @@ def __init__(self, format='%(percentage)3d%%', na='N/A%%', **kwargs): WidgetBase.__init__(self, format=format, **kwargs) def get_format( - self, - progress: ProgressBarMixinBase, - data: Data, - format=None, + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, ): # If percentage is not available, display N/A% percentage = data.get('percentage', base.Undefined) @@ -933,10 +942,10 @@ def __init__(self, format=DEFAULT_FORMAT, **kwargs): self.max_width_cache = dict(default=self.max_width or 0) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format=None, + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, ): # If max_value is not available, display N/A if data.get('max_value'): @@ -971,12 +980,12 @@ def __call__( temporary_data['value'] = value if width := progress.custom_len( # pragma: no branch - FormatWidgetMixin.__call__( - self, - progress, - temporary_data, - format=format, - ), + FormatWidgetMixin.__call__( + self, + progress, + temporary_data, + format=format, + ), ): max_width = max(max_width or 0, width) @@ -996,14 +1005,14 @@ class Bar(AutoWidthWidgetBase): bg: terminal.OptionalColor | None = None def __init__( - self, - marker='#', - left='|', - right='|', - fill=' ', - fill_left=True, - marker_wrap=None, - **kwargs, + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=True, + marker_wrap=None, + **kwargs, ): '''Creates a customizable progress bar. @@ -1024,11 +1033,11 @@ def __init__( AutoWidthWidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - color=True, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, ): '''Updates the progress bar and its subcomponents.''' left = converters.to_unicode(self.left(progress, data, width)) @@ -1055,13 +1064,13 @@ class ReverseBar(Bar): '''A bar which has a marker that goes from right to left.''' def __init__( - self, - marker='#', - left='|', - right='|', - fill=' ', - fill_left=False, - **kwargs, + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=False, + **kwargs, ): '''Creates a customizable progress bar. @@ -1088,11 +1097,11 @@ class BouncingBar(Bar, TimeSensitiveWidgetBase): INTERVAL = datetime.timedelta(milliseconds=100) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - color=True, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, ): '''Updates the progress bar and its subcomponents.''' left = converters.to_unicode(self.left(progress, data, width)) @@ -1125,10 +1134,10 @@ class FormatCustomText(FormatWidgetMixin, WidgetBase): copy = False def __init__( - self, - format: str, - mapping: types.Optional[types.Dict[str, types.Any]] = None, - **kwargs, + self, + format: str, + mapping: types.Optional[types.Dict[str, types.Any]] = None, + **kwargs, ): self.format = format self.mapping = mapping or self.mapping @@ -1139,10 +1148,10 @@ def update_mapping(self, **mapping: types.Dict[str, types.Any]): self.mapping.update(mapping) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): return FormatWidgetMixin.__call__( self, @@ -1187,11 +1196,11 @@ def get_values(self, progress: ProgressBarMixinBase, data: Data): return data['variables'][self.name] or [] def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - color=True, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, ): '''Updates the progress bar and its subcomponents.''' left = converters.to_unicode(self.left(progress, data, width)) @@ -1223,12 +1232,12 @@ def __call__( 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, + self, + name, + # NOTE: the markers are not whitespace even though some + # terminals don't show the characters correctly! + markers=' ▁▂▃▄▅▆▇█', + **kwargs, ): MultiRangeBar.__init__( self, @@ -1289,11 +1298,11 @@ class GranularBar(AutoWidthWidgetBase): ''' def __init__( - self, - markers=GranularMarkers.smooth, - left='|', - right='|', - **kwargs, + self, + markers=GranularMarkers.smooth, + left='|', + right='|', + **kwargs, ): '''Creates a customizable progress bar. @@ -1310,10 +1319,10 @@ def __init__( AutoWidthWidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, ): left = converters.to_unicode(self.left(progress, data, width)) right = converters.to_unicode(self.right(progress, data, width)) @@ -1323,8 +1332,8 @@ def __call__( # mypy doesn't get that the first part of the if statement makes sure # we get the correct type if ( - max_value is not base.UnknownLength - and max_value > 0 # type: ignore + max_value is not base.UnknownLength + and max_value > 0 # type: ignore ): percent = progress.value / max_value # type: ignore else: @@ -1354,11 +1363,11 @@ def __init__(self, format, **kwargs): Bar.__init__(self, **kwargs) def __call__( # type: ignore - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - format: FormatString = None, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + format: FormatString = None, ): center = FormatLabel.__call__(self, progress, data, format=format) bar = Bar.__call__(self, progress, data, width, color=False) @@ -1369,18 +1378,18 @@ def __call__( # type: ignore center_right = center_left + center_len return ( - self._apply_colors( - bar[:center_left], - data, - ) - + self._apply_colors( - center, - data, - ) - + self._apply_colors( - bar[center_right:], - data, - ) + self._apply_colors( + bar[:center_left], + data, + ) + + self._apply_colors( + center, + data, + ) + + self._apply_colors( + bar[center_right:], + data, + ) ) @@ -1394,11 +1403,11 @@ def __init__(self, format='%(percentage)2d%%', na='N/A%%', **kwargs): FormatLabelBar.__init__(self, format, **kwargs) def __call__( # type: ignore - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - format: FormatString = None, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + format: FormatString = None, ): return super().__call__(progress, data, width, format=format) @@ -1407,12 +1416,12 @@ class Variable(FormatWidgetMixin, VariableMixin, WidgetBase): '''Displays a custom variable.''' def __init__( - self, - name, - format='{name}: {formatted_value}', - width=6, - precision=3, - **kwargs, + self, + name, + format='{name}: {formatted_value}', + width=6, + precision=3, + **kwargs, ): '''Creates a Variable associated with the given name.''' self.format = format @@ -1422,10 +1431,10 @@ def __init__( WidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): value = data['variables'][self.name] context = data.copy() @@ -1461,20 +1470,20 @@ class CurrentTime(FormatWidgetMixin, TimeSensitiveWidgetBase): INTERVAL = datetime.timedelta(seconds=1) def __init__( - self, - format='Current Time: %(current_time)s', - microseconds=False, - **kwargs, + self, + format='Current Time: %(current_time)s', + microseconds=False, + **kwargs, ): self.microseconds = microseconds FormatWidgetMixin.__init__(self, format=format, **kwargs) TimeSensitiveWidgetBase.__init__(self, **kwargs) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - format: types.Optional[str] = None, + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, ): data['current_time'] = self.current_time() data['current_datetime'] = self.current_datetime() @@ -1524,19 +1533,19 @@ class JobStatusBar(Bar, VariableMixin): job_markers: list[str] def __init__( - self, - name: str, - left='|', - right='|', - fill=' ', - fill_left=True, - success_fg_color=colors.green, - success_bg_color=None, - success_marker='█', - failure_fg_color=colors.red, - failure_bg_color=None, - failure_marker='X', - **kwargs, + self, + name: str, + left='|', + right='|', + fill=' ', + fill_left=True, + success_fg_color=colors.green, + success_bg_color=None, + success_marker='█', + failure_fg_color=colors.red, + failure_bg_color=None, + failure_marker='X', + **kwargs, ): VariableMixin.__init__(self, name) self.name = name @@ -1561,11 +1570,11 @@ def __init__( ) def __call__( - self, - progress: ProgressBarMixinBase, - data: Data, - width: int = 0, - color=True, + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, ): left = converters.to_unicode(self.left(progress, data, width)) right = converters.to_unicode(self.right(progress, data, width)) diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index e7128d0..85027ce 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -1,6 +1,6 @@ -import pytest from datetime import timedelta +import pytest from progressbar import algorithms @@ -17,7 +17,7 @@ def test_ema_initialization(): (0.7, 40, 28), (0.5, 0, 0), (0.2, 100, 20), - (0.8, 50, 40) + (0.8, 50, 40), ]) def test_ema_update(alpha, new_value, expected): ema = algorithms.ExponentialMovingAverage(alpha) @@ -37,7 +37,7 @@ def test_dema_initialization(): (0.3, 15, 7.65), (0.5, 0, 0), (0.2, 100, 36.0), - (0.8, 50, 48.0) + (0.8, 50, 48.0), ]) def test_dema_update(alpha, new_value, expected): dema = algorithms.DoubleExponentialMovingAverage(alpha) diff --git a/tests/test_monitor_progress.py b/tests/test_monitor_progress.py index 66661d4..e49e4d4 100644 --- a/tests/test_monitor_progress.py +++ b/tests/test_monitor_progress.py @@ -140,7 +140,8 @@ def test_rapid_updates(testdir): ) result.stderr.lines = _non_empty_lines(result.stderr.lines) pprint.pprint(result.stderr.lines, width=70) - result.stderr.fnmatch_lines([' 0% (0 of 10) | | Elapsed Time: 0:00:00 ETA: --:--:--', + result.stderr.fnmatch_lines([ + ' 0% (0 of 10) | | Elapsed Time: 0:00:00 ETA: --:--:--', ' 10% (1 of 10) | | Elapsed Time: 0:00:01 ETA: 0:00:09', ' 20% (2 of 10) |# | Elapsed Time: 0:00:02 ETA: 0:00:08', ' 30% (3 of 10) |# | Elapsed Time: 0:00:03 ETA: 0:00:07', @@ -151,7 +152,7 @@ def test_rapid_updates(testdir): ' 80% (8 of 10) |#### | Elapsed Time: 0:00:11 ETA: 0:00:02', ' 90% (9 of 10) |##### | Elapsed Time: 0:00:13 ETA: 0:00:01', '100% (10 of 10) |#####| Elapsed Time: 0:00:15 Time: 0:00:15', - ] + ], ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2f03062..448a8c8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,4 @@ import io -import os import progressbar import progressbar.env diff --git a/tests/test_windows.py b/tests/test_windows.py index 48e7c54..51bed5c 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -1,5 +1,6 @@ -import time import sys +import time + import pytest if sys.platform.startswith('win'): @@ -36,8 +37,10 @@ def scrape_console(line_count): # --------------------------------------------------------------------------- def runprogress(): print('***BEGIN***') - b = progressbar.ProgressBar(widgets=['example.m4v: '] + _WIDGETS, - max_value=10 * _MB) + b = progressbar.ProgressBar( + widgets=['example.m4v: ', *_WIDGETS], + max_value=10 * _MB, + ) for i in range(10): b.update((i + 1) * _MB) time.sleep(0.25) @@ -47,9 +50,9 @@ def runprogress(): # --------------------------------------------------------------------------- -def find(L, x): +def find(lines, x): try: - return L.index(x) + return lines.index(x) except ValueError: return -sys.maxsize @@ -59,7 +62,8 @@ def test_windows(): runprogress() scraped_lines = scrape_console(100) - scraped_lines.reverse() # reverse lines so we find the LAST instances of output. + # reverse lines so we find the LAST instances of output. + scraped_lines.reverse() index_begin = find(scraped_lines, '***BEGIN***') index_end = find(scraped_lines, '***END***') From 5707548cc9dc677c6b66f119bc09bf595eaecb79 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 18 Feb 2024 23:24:48 +0100 Subject: [PATCH 09/17] Added progressbar command for commandline progressbars --- docs/progressbar.algorithms.rst | 7 + progressbar/__main__.py | 279 ++++++++++++++++++++++++++++++ pyproject.toml | 4 +- tests/test_progressbar_command.py | 108 ++++++++++++ 4 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 docs/progressbar.algorithms.rst create mode 100644 progressbar/__main__.py create mode 100644 tests/test_progressbar_command.py diff --git a/docs/progressbar.algorithms.rst b/docs/progressbar.algorithms.rst new file mode 100644 index 0000000..bf239d7 --- /dev/null +++ b/docs/progressbar.algorithms.rst @@ -0,0 +1,7 @@ +progressbar.algorithms module +============================= + +.. automodule:: progressbar.algorithms + :members: + :undoc-members: + :show-inheritance: diff --git a/progressbar/__main__.py b/progressbar/__main__.py new file mode 100644 index 0000000..c4ab0c2 --- /dev/null +++ b/progressbar/__main__.py @@ -0,0 +1,279 @@ +import argparse +import contextlib +import pathlib +import sys +import time +from typing import BinaryIO + +import progressbar + + +def size_to_bytes(size_str: str) -> int: + ''' + Convert a size string with suffixes 'k', 'm', etc., to bytes. + + Note: This function also supports '@' as a prefix to a file path to get the + file size. + + >>> size_to_bytes('1024k') + 1048576 + >>> size_to_bytes('1024m') + 1073741824 + >>> size_to_bytes('1024g') + 1099511627776 + >>> size_to_bytes('1024') + 1024 + >>> size_to_bytes('1024p') + 1125899906842624 + ''' + + # Define conversion rates + suffix_exponent = { + 'k': 1, + 'm': 2, + 'g': 3, + 't': 4, + 'p': 5, + } + + # Initialize the default exponent to 0 (for bytes) + exponent = 0 + + # Check if the size starts with '@' (for file sizes, not handled here) + if size_str.startswith('@'): + return pathlib.Path(size_str[1:]).stat().st_size + + # Check if the last character is a known suffix and adjust the multiplier + if size_str[-1].lower() in suffix_exponent: + # Update exponent based on the suffix + exponent = suffix_exponent[size_str[-1].lower()] + # Remove the suffix from the size_str + size_str = size_str[:-1] + + # Convert the size_str to an integer and apply the exponent + return int(size_str) * (1024 ** exponent) + + +def create_argument_parser() -> argparse.ArgumentParser: + ''' + Create the argument parser for the `progressbar` command. + + >>> parser = create_argument_parser() + >>> parser.parse_args(['-p', '-t', '-e', '-r', '-a', '-b', '-8', '-T', '-A', '-F', '-n', '-q', 'input', '-o', 'output']) + Namespace(average_rate=True, bytes=True, eta=True, fineta=False, format=None, height=None, input=['input'], interval=None, last_written=None, line_mode=False, name=None, numeric=True, output='output', progress=True, quiet=True, rate=True, rate_limit=None, remote=None, size=None, stop_at_size=False, sync=False, timer=True, wait=False, watchfd=None, width=None) + + Returns: + argparse.ArgumentParser: The argument parser for the `progressbar` command. + ''' + + parser = argparse.ArgumentParser( + description=''' + Monitor the progress of data through a pipe. + + Note that this is a Python implementation of the original `pv` command + that is functional but not yet feature complete. + ''') + + # Display switches + parser.add_argument('-p', '--progress', action='store_true', + help='Turn the progress bar on.') + parser.add_argument('-t', '--timer', action='store_true', + help='Turn the timer on.') + parser.add_argument('-e', '--eta', action='store_true', + help='Turn the ETA timer on.') + parser.add_argument('-I', '--fineta', action='store_true', + help='Display the ETA as local time of arrival.') + parser.add_argument('-r', '--rate', action='store_true', + help='Turn the rate counter on.') + parser.add_argument('-a', '--average-rate', action='store_true', + help='Turn the average rate counter on.') + parser.add_argument('-b', '--bytes', action='store_true', + help='Turn the total byte counter on.') + parser.add_argument('-8', '--bits', action='store_true', + help='Display total bits instead of bytes.') + parser.add_argument('-T', '--buffer-percent', action='store_true', + help='Turn on the transfer buffer percentage display.') + parser.add_argument('-A', '--last-written', type=int, + help='Show the last NUM bytes written.') + parser.add_argument('-F', '--format', type=str, + help='Use the format string FORMAT for output format.') + parser.add_argument('-n', '--numeric', action='store_true', + help='Numeric output.') + parser.add_argument('-q', '--quiet', action='store_true', help='No output.') + + # Output modifiers + parser.add_argument('-W', '--wait', action='store_true', + help='Wait until the first byte has been transferred.') + parser.add_argument('-D', '--delay-start', type=float, help='Delay start.') + parser.add_argument('-s', '--size', type=str, + help='Assume total data size is SIZE.') + parser.add_argument('-l', '--line-mode', action='store_true', + help='Count lines instead of bytes.') + parser.add_argument('-0', '--null', action='store_true', + help='Count lines terminated with a zero byte.') + parser.add_argument('-i', '--interval', type=float, + help='Interval between updates.') + parser.add_argument('-m', '--average-rate-window', type=int, + help='Window for average rate calculation.') + parser.add_argument('-w', '--width', type=int, + help='Assume terminal is WIDTH characters wide.') + parser.add_argument('-H', '--height', type=int, + help='Assume terminal is HEIGHT rows high.') + parser.add_argument('-N', '--name', type=str, + help='Prefix output information with NAME.') + parser.add_argument('-f', '--force', action='store_true', + help='Force output.') + parser.add_argument('-c', '--cursor', action='store_true', + help='Use cursor positioning escape sequences.') + + # Data transfer modifiers + parser.add_argument('-L', '--rate-limit', type=str, + help='Limit transfer to RATE bytes per second.') + parser.add_argument('-B', '--buffer-size', type=str, + help='Use transfer buffer size of BYTES.') + parser.add_argument('-C', '--no-splice', action='store_true', + help='Never use splice.') + parser.add_argument('-E', '--skip-errors', action='store_true', + help='Ignore read errors.') + parser.add_argument('-Z', '--error-skip-block', type=str, + help='Skip block size when ignoring errors.') + parser.add_argument('-S', '--stop-at-size', action='store_true', + help='Stop transferring after SIZE bytes.') + parser.add_argument('-Y', '--sync', action='store_true', + help='Synchronise buffer caches to disk after writes.') + parser.add_argument('-K', '--direct-io', action='store_true', + help='Set O_DIRECT flag on all inputs/outputs.') + parser.add_argument('-X', '--discard', action='store_true', + help='Discard input data instead of transferring it.') + parser.add_argument('-d', '--watchfd', type=str, + help='Watch file descriptor of process.') + parser.add_argument('-R', '--remote', type=int, + help='Remote control another running instance of pv.') + + # General options + parser.add_argument('-P', '--pidfile', type=pathlib.Path, + help='Save process ID in FILE.') + parser.add_argument( + 'input', + help='Input file path. Uses stdin if not specified.', + default='-', + nargs='*', + ) + parser.add_argument( + '-o', + '--output', + default='-', + help='Output file path. Uses stdout if not specified.') + + return parser + + +def main(argv: list[str] = sys.argv[1:]): + ''' + Main function for the `progressbar` command. + ''' + parser = create_argument_parser() + args = parser.parse_args(argv) + + binary_mode = '' if args.line_mode else 'b' + + with contextlib.ExitStack() as stack: + if args.output and args.output != '-': + output_stream = stack.enter_context( + open(args.output, 'w' + binary_mode)) + else: + if args.line_mode: + output_stream = sys.stdout + else: + output_stream = sys.stdout.buffer + + input_paths = [] + total_size = 0 + filesize_available = True + for filename in args.input: + input_path: BinaryIO | pathlib.Path + if filename == '-': + if args.line_mode: + input_path = sys.stdin + else: + input_path = sys.stdin.buffer + + filesize_available = False + else: + input_path = pathlib.Path(filename) + if not input_path.exists(): + parser.error(f'File not found: {filename}') + + if not args.size: + total_size += input_path.stat().st_size + + input_paths.append(input_path) + + # Determine the size for the progress bar (if provided) + if args.size: + total_size = size_to_bytes(args.size) + filesize_available = True + + if filesize_available: + # Create the progress bar components + widgets = [ + progressbar.Percentage(), + ' ', + progressbar.Bar(), + ' ', + progressbar.Timer(), + ' ', + progressbar.FileTransferSpeed(), + ] + else: + widgets = [ + progressbar.SimpleProgress(), + ' ', + progressbar.DataSize(), + ' ', + progressbar.Timer(), + ] + + if args.eta: + widgets.append(' ') + widgets.append(progressbar.AdaptiveETA()) + + # Initialize the progress bar + bar = progressbar.ProgressBar( + # widgets=widgets, + max_value=total_size or None, + max_error=False, + ) + + # Data processing and updating the progress bar + buffer_size = size_to_bytes( + args.buffer_size) if args.buffer_size else 1024 + total_transferred = 0 + + bar.start() + with contextlib.suppress(KeyboardInterrupt): + for input_path in input_paths: + if isinstance(input_path, pathlib.Path): + input_stream = stack.enter_context( + input_path.open('r' + binary_mode)) + else: + input_stream = input_path + + while True: + if args.line_mode: + data = input_stream.readline(buffer_size) + else: + data = input_stream.read(buffer_size) + + if not data: + break + + output_stream.write(data) + total_transferred += len(data) + bar.update(total_transferred) + + bar.finish(dirty=True) + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml index a6d553d..904e717 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,8 +108,8 @@ exclude = ['docs*', 'tests*'] [tool.setuptools] include-package-data = true -# [project.scripts] -# progressbar2 = 'progressbar.cli:main' +[project.scripts] +progressbar = 'progressbar.cli:main' [project.optional-dependencies] docs = ['sphinx>=1.8.5', 'sphinx-autodoc-typehints>=1.6.0'] diff --git a/tests/test_progressbar_command.py b/tests/test_progressbar_command.py new file mode 100644 index 0000000..8cce715 --- /dev/null +++ b/tests/test_progressbar_command.py @@ -0,0 +1,108 @@ +import io +import os.path + +import pytest + +import progressbar.__main__ as main + + +def test_size_to_bytes(): + assert main.size_to_bytes('1') == 1 + assert main.size_to_bytes('1k') == 1024 + assert main.size_to_bytes('1m') == 1048576 + assert main.size_to_bytes('1g') == 1073741824 + assert main.size_to_bytes('1p') == 1125899906842624 + + assert main.size_to_bytes('1024') == 1024 + assert main.size_to_bytes('1024k') == 1048576 + assert main.size_to_bytes('1024m') == 1073741824 + assert main.size_to_bytes('1024g') == 1099511627776 + assert main.size_to_bytes('1024p') == 1152921504606846976 + + +def test_filename_to_bytes(tmp_path): + file = tmp_path / 'test' + file.write_text('test') + assert main.size_to_bytes(f'@{file}') == 4 + + with pytest.raises(FileNotFoundError): + main.size_to_bytes(f'@{tmp_path / "nonexistent"}') + + +def test_create_argument_parser(): + parser = main.create_argument_parser() + args = parser.parse_args( + ['-p', '-t', '-e', '-r', '-a', '-b', '-8', '-T', '-n', '-q', + 'input', '-o', 'output']) + assert args.progress is True + assert args.timer is True + assert args.eta is True + assert args.rate is True + assert args.average_rate is True + assert args.bytes is True + assert args.bits is True + assert args.buffer_percent is True + assert args.last_written is None + assert args.format is None + assert args.numeric is True + assert args.quiet is True + assert args.input == ['input'] + assert args.output == 'output' + + +def test_main_binary(capsys): + # Call the main function with different command line arguments + main.main( + ['-p', '-t', '-e', '-r', '-a', '-b', '-8', '-T', '-n', '-q', __file__]) + + captured = capsys.readouterr() + assert 'test_main(capsys):' in captured.out + # TODO: Capture the output and check that it is correct + # assert '' in captured.err + + +def test_main_lines(capsys): + # Call the main function with different command line arguments + main.main( + ['-p', '-t', '-e', '-r', '-a', '-b', '-8', '-T', '-n', '-q', '-l', + '-s', f'@{__file__}', + __file__]) + + captured = capsys.readouterr() + assert 'test_main(capsys):' in captured.out + # TODO: Capture the output and check that it is correct + # assert '' in captured.err + + +class Input(io.StringIO): + buffer: io.BytesIO + + @classmethod + def create(cls, text: str): + instance = cls(text) + instance.buffer = io.BytesIO(text.encode()) + return instance + + +def test_main_lines_output(monkeypatch, tmp_path): + text = 'my input' + monkeypatch.setattr('sys.stdin', Input.create(text)) + output_filename = tmp_path / 'output' + main.main(['-l', '-o', str(output_filename)]) + + assert output_filename.read_text() == text + + +def test_main_bytes_output(monkeypatch, tmp_path): + text = 'my input' + + monkeypatch.setattr('sys.stdin', Input.create(text)) + output_filename = tmp_path / 'output' + main.main(['-o', str(output_filename)]) + + assert output_filename.read_text() == f'{text}' + + +def test_missing_input(tmp_path): + with pytest.raises(SystemExit): + main.main([str(tmp_path / 'output')]) From afc42ffe2b0dd132c301433e2bdb88b98605eb85 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Tue, 20 Feb 2024 23:33:29 +0100 Subject: [PATCH 10/17] ruff fixes --- progressbar/__main__.py | 274 ++++++++++++++++++++---------- progressbar/bar.py | 12 +- progressbar/env.py | 6 +- progressbar/multi.py | 3 +- progressbar/terminal/base.py | 30 ++-- progressbar/widgets.py | 3 +- ruff.toml | 39 ++++- tests/test_monitor_progress.py | 26 ++- tests/test_progressbar_command.py | 8 +- 9 files changed, 260 insertions(+), 141 deletions(-) diff --git a/progressbar/__main__.py b/progressbar/__main__.py index c4ab0c2..1d91f90 100644 --- a/progressbar/__main__.py +++ b/progressbar/__main__.py @@ -1,8 +1,9 @@ +from __future__ import annotations + import argparse import contextlib import pathlib import sys -import time from typing import BinaryIO import progressbar @@ -57,13 +58,6 @@ def size_to_bytes(size_str: str) -> int: def create_argument_parser() -> argparse.ArgumentParser: ''' Create the argument parser for the `progressbar` command. - - >>> parser = create_argument_parser() - >>> parser.parse_args(['-p', '-t', '-e', '-r', '-a', '-b', '-8', '-T', '-A', '-F', '-n', '-q', 'input', '-o', 'output']) - Namespace(average_rate=True, bytes=True, eta=True, fineta=False, format=None, height=None, input=['input'], interval=None, last_written=None, line_mode=False, name=None, numeric=True, output='output', progress=True, quiet=True, rate=True, rate_limit=None, remote=None, size=None, stop_at_size=False, sync=False, timer=True, wait=False, watchfd=None, width=None) - - Returns: - argparse.ArgumentParser: The argument parser for the `progressbar` command. ''' parser = argparse.ArgumentParser( @@ -72,87 +66,191 @@ def create_argument_parser() -> argparse.ArgumentParser: Note that this is a Python implementation of the original `pv` command that is functional but not yet feature complete. - ''') + ''' + ) # Display switches - parser.add_argument('-p', '--progress', action='store_true', - help='Turn the progress bar on.') - parser.add_argument('-t', '--timer', action='store_true', - help='Turn the timer on.') - parser.add_argument('-e', '--eta', action='store_true', - help='Turn the ETA timer on.') - parser.add_argument('-I', '--fineta', action='store_true', - help='Display the ETA as local time of arrival.') - parser.add_argument('-r', '--rate', action='store_true', - help='Turn the rate counter on.') - parser.add_argument('-a', '--average-rate', action='store_true', - help='Turn the average rate counter on.') - parser.add_argument('-b', '--bytes', action='store_true', - help='Turn the total byte counter on.') - parser.add_argument('-8', '--bits', action='store_true', - help='Display total bits instead of bytes.') - parser.add_argument('-T', '--buffer-percent', action='store_true', - help='Turn on the transfer buffer percentage display.') - parser.add_argument('-A', '--last-written', type=int, - help='Show the last NUM bytes written.') - parser.add_argument('-F', '--format', type=str, - help='Use the format string FORMAT for output format.') - parser.add_argument('-n', '--numeric', action='store_true', - help='Numeric output.') - parser.add_argument('-q', '--quiet', action='store_true', help='No output.') + parser.add_argument( + '-p', + '--progress', + action='store_true', + help='Turn the progress bar on.', + ) + parser.add_argument( + '-t', '--timer', action='store_true', help='Turn the timer on.' + ) + parser.add_argument( + '-e', '--eta', action='store_true', help='Turn the ETA timer on.' + ) + parser.add_argument( + '-I', + '--fineta', + action='store_true', + help='Display the ETA as local time of arrival.', + ) + parser.add_argument( + '-r', '--rate', action='store_true', help='Turn the rate counter on.' + ) + parser.add_argument( + '-a', + '--average-rate', + action='store_true', + help='Turn the average rate counter on.', + ) + parser.add_argument( + '-b', + '--bytes', + action='store_true', + help='Turn the total byte counter on.', + ) + parser.add_argument( + '-8', + '--bits', + action='store_true', + help='Display total bits instead of bytes.', + ) + parser.add_argument( + '-T', + '--buffer-percent', + action='store_true', + help='Turn on the transfer buffer percentage display.', + ) + parser.add_argument( + '-A', + '--last-written', + type=int, + help='Show the last NUM bytes written.', + ) + parser.add_argument( + '-F', + '--format', + type=str, + help='Use the format string FORMAT for output format.', + ) + parser.add_argument( + '-n', '--numeric', action='store_true', help='Numeric output.' + ) + parser.add_argument( + '-q', '--quiet', action='store_true', help='No output.' + ) # Output modifiers - parser.add_argument('-W', '--wait', action='store_true', - help='Wait until the first byte has been transferred.') + parser.add_argument( + '-W', + '--wait', + action='store_true', + help='Wait until the first byte has been transferred.', + ) parser.add_argument('-D', '--delay-start', type=float, help='Delay start.') - parser.add_argument('-s', '--size', type=str, - help='Assume total data size is SIZE.') - parser.add_argument('-l', '--line-mode', action='store_true', - help='Count lines instead of bytes.') - parser.add_argument('-0', '--null', action='store_true', - help='Count lines terminated with a zero byte.') - parser.add_argument('-i', '--interval', type=float, - help='Interval between updates.') - parser.add_argument('-m', '--average-rate-window', type=int, - help='Window for average rate calculation.') - parser.add_argument('-w', '--width', type=int, - help='Assume terminal is WIDTH characters wide.') - parser.add_argument('-H', '--height', type=int, - help='Assume terminal is HEIGHT rows high.') - parser.add_argument('-N', '--name', type=str, - help='Prefix output information with NAME.') - parser.add_argument('-f', '--force', action='store_true', - help='Force output.') - parser.add_argument('-c', '--cursor', action='store_true', - help='Use cursor positioning escape sequences.') + parser.add_argument( + '-s', '--size', type=str, help='Assume total data size is SIZE.' + ) + parser.add_argument( + '-l', + '--line-mode', + action='store_true', + help='Count lines instead of bytes.', + ) + parser.add_argument( + '-0', + '--null', + action='store_true', + help='Count lines terminated with a zero byte.', + ) + parser.add_argument( + '-i', '--interval', type=float, help='Interval between updates.' + ) + parser.add_argument( + '-m', + '--average-rate-window', + type=int, + help='Window for average rate calculation.', + ) + parser.add_argument( + '-w', + '--width', + type=int, + help='Assume terminal is WIDTH characters wide.', + ) + parser.add_argument( + '-H', '--height', type=int, help='Assume terminal is HEIGHT rows high.' + ) + parser.add_argument( + '-N', '--name', type=str, help='Prefix output information with NAME.' + ) + parser.add_argument( + '-f', '--force', action='store_true', help='Force output.' + ) + parser.add_argument( + '-c', + '--cursor', + action='store_true', + help='Use cursor positioning escape sequences.', + ) # Data transfer modifiers - parser.add_argument('-L', '--rate-limit', type=str, - help='Limit transfer to RATE bytes per second.') - parser.add_argument('-B', '--buffer-size', type=str, - help='Use transfer buffer size of BYTES.') - parser.add_argument('-C', '--no-splice', action='store_true', - help='Never use splice.') - parser.add_argument('-E', '--skip-errors', action='store_true', - help='Ignore read errors.') - parser.add_argument('-Z', '--error-skip-block', type=str, - help='Skip block size when ignoring errors.') - parser.add_argument('-S', '--stop-at-size', action='store_true', - help='Stop transferring after SIZE bytes.') - parser.add_argument('-Y', '--sync', action='store_true', - help='Synchronise buffer caches to disk after writes.') - parser.add_argument('-K', '--direct-io', action='store_true', - help='Set O_DIRECT flag on all inputs/outputs.') - parser.add_argument('-X', '--discard', action='store_true', - help='Discard input data instead of transferring it.') - parser.add_argument('-d', '--watchfd', type=str, - help='Watch file descriptor of process.') - parser.add_argument('-R', '--remote', type=int, - help='Remote control another running instance of pv.') + parser.add_argument( + '-L', + '--rate-limit', + type=str, + help='Limit transfer to RATE bytes per second.', + ) + parser.add_argument( + '-B', + '--buffer-size', + type=str, + help='Use transfer buffer size of BYTES.', + ) + parser.add_argument( + '-C', '--no-splice', action='store_true', help='Never use splice.' + ) + parser.add_argument( + '-E', '--skip-errors', action='store_true', help='Ignore read errors.' + ) + parser.add_argument( + '-Z', + '--error-skip-block', + type=str, + help='Skip block size when ignoring errors.', + ) + parser.add_argument( + '-S', + '--stop-at-size', + action='store_true', + help='Stop transferring after SIZE bytes.', + ) + parser.add_argument( + '-Y', + '--sync', + action='store_true', + help='Synchronise buffer caches to disk after writes.', + ) + parser.add_argument( + '-K', + '--direct-io', + action='store_true', + help='Set O_DIRECT flag on all inputs/outputs.', + ) + parser.add_argument( + '-X', + '--discard', + action='store_true', + help='Discard input data instead of transferring it.', + ) + parser.add_argument( + '-d', '--watchfd', type=str, help='Watch file descriptor of process.' + ) + parser.add_argument( + '-R', + '--remote', + type=int, + help='Remote control another running instance of pv.', + ) # General options - parser.add_argument('-P', '--pidfile', type=pathlib.Path, - help='Save process ID in FILE.') + parser.add_argument( + '-P', '--pidfile', type=pathlib.Path, help='Save process ID in FILE.' + ) parser.add_argument( 'input', help='Input file path. Uses stdin if not specified.', @@ -163,12 +261,13 @@ def create_argument_parser() -> argparse.ArgumentParser: '-o', '--output', default='-', - help='Output file path. Uses stdout if not specified.') + help='Output file path. Uses stdout if not specified.', + ) return parser -def main(argv: list[str] = sys.argv[1:]): +def main(argv: list[str] | None = None): # noqa: C901 ''' Main function for the `progressbar` command. ''' @@ -180,7 +279,8 @@ def main(argv: list[str] = sys.argv[1:]): with contextlib.ExitStack() as stack: if args.output and args.output != '-': output_stream = stack.enter_context( - open(args.output, 'w' + binary_mode)) + open(args.output, 'w' + binary_mode) + ) else: if args.line_mode: output_stream = sys.stdout @@ -246,8 +346,9 @@ def main(argv: list[str] = sys.argv[1:]): ) # Data processing and updating the progress bar - buffer_size = size_to_bytes( - args.buffer_size) if args.buffer_size else 1024 + buffer_size = ( + size_to_bytes(args.buffer_size) if args.buffer_size else 1024 + ) total_transferred = 0 bar.start() @@ -255,7 +356,8 @@ def main(argv: list[str] = sys.argv[1:]): for input_path in input_paths: if isinstance(input_path, pathlib.Path): input_stream = stack.enter_context( - input_path.open('r' + binary_mode)) + input_path.open('r' + binary_mode) + ) else: input_stream = input_path diff --git a/progressbar/bar.py b/progressbar/bar.py index 4cfc835..e816498 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -252,21 +252,21 @@ def _determine_enable_colors( for color_enabled in colors: if color_enabled is not None: if color_enabled: - enable_colors = progressbar.env.COLOR_SUPPORT + enable = progressbar.env.COLOR_SUPPORT else: - enable_colors = progressbar.env.ColorSupport.NONE + enable = progressbar.env.ColorSupport.NONE break else: - enable_colors = False + enable = False elif enable_colors is True: - enable_colors = progressbar.env.ColorSupport.XTERM_256 + enable = progressbar.env.ColorSupport.XTERM_256 elif enable_colors is False: - enable_colors = progressbar.env.ColorSupport.NONE + enable = progressbar.env.ColorSupport.NONE elif not isinstance(enable_colors, progressbar.env.ColorSupport): raise ValueError(f'Invalid color support value: {enable_colors}') - return enable_colors + return enable def print(self, *args: types.Any, **kwargs: types.Any) -> None: print(*args, file=self.fd, **kwargs) diff --git a/progressbar/env.py b/progressbar/env.py index 8a45953..2a4e175 100644 --- a/progressbar/env.py +++ b/progressbar/env.py @@ -10,13 +10,11 @@ @typing.overload -def env_flag(name: str, default: bool) -> bool: - ... +def env_flag(name: str, default: bool) -> bool: ... @typing.overload -def env_flag(name: str, default: bool | None = None) -> bool | None: - ... +def env_flag(name: str, default: bool | None = None) -> bool | None: ... def env_flag(name, default=None): diff --git a/progressbar/multi.py b/progressbar/multi.py index be1ca7d..ae3dd23 100644 --- a/progressbar/multi.py +++ b/progressbar/multi.py @@ -129,7 +129,8 @@ def __setitem__(self, key: str, bar: bar.ProgressBar): bar.label = key bar.fd = stream.LastLineStream(self.fd) bar.paused = True - # Essentially `bar.print = self.print`, but `mypy` doesn't like that + # Essentially `bar.print = self.print`, but `mypy` doesn't + # like that bar.print = self.print # type: ignore # Just in case someone is using a progressbar with a custom diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index 55c031e..895887b 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -426,21 +426,21 @@ def __hash__(self): class Colors: - by_name: ClassVar[ - defaultdict[str, types.List[Color]] - ] = collections.defaultdict(list) - by_lowername: ClassVar[ - defaultdict[str, types.List[Color]] - ] = collections.defaultdict(list) - by_hex: ClassVar[ - defaultdict[str, types.List[Color]] - ] = collections.defaultdict(list) - by_rgb: ClassVar[ - defaultdict[RGB, types.List[Color]] - ] = collections.defaultdict(list) - by_hls: ClassVar[ - defaultdict[HSL, types.List[Color]] - ] = collections.defaultdict(list) + by_name: ClassVar[defaultdict[str, types.List[Color]]] = ( + collections.defaultdict(list) + ) + by_lowername: ClassVar[defaultdict[str, types.List[Color]]] = ( + collections.defaultdict(list) + ) + by_hex: ClassVar[defaultdict[str, types.List[Color]]] = ( + collections.defaultdict(list) + ) + by_rgb: ClassVar[defaultdict[RGB, types.List[Color]]] = ( + collections.defaultdict(list) + ) + by_hls: ClassVar[defaultdict[HSL, types.List[Color]]] = ( + collections.defaultdict(list) + ) by_xterm: ClassVar[dict[int, Color]] = dict() @classmethod diff --git a/progressbar/widgets.py b/progressbar/widgets.py index ed6e68e..e5046b6 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1256,7 +1256,8 @@ def get_values(self, progress: ProgressBarMixinBase, data: Data): if not 0 <= value <= 1: raise ValueError( - f'Range value needs to be in the range [0..1], got {value}', + 'Range value needs to be in the range [0..1], ' + f'got {value}', ) range_ = value * (len(ranges) - 1) diff --git a/ruff.toml b/ruff.toml index 083e321..90f115b 100644 --- a/ruff.toml +++ b/ruff.toml @@ -5,7 +5,7 @@ target-version = 'py38' src = ['progressbar'] -ignore = [ +lint.ignore = [ 'A001', # Variable {name} is shadowing a Python builtin 'A002', # Argument {name} is shadowing a Python builtin 'A003', # Class attribute {name} is shadowing a Python builtin @@ -21,9 +21,14 @@ ignore = [ 'C408', # Unnecessary {obj_type} call (rewrite as a literal) 'SIM114', # Combine `if` branches using logical `or` operator 'RET506', # Unnecessary `else` after `raise` statement + 'Q001', # Remove bad quotes + 'Q002', # Remove bad quotes + 'COM812', # Missing trailing comma in a list + 'ISC001', # String concatenation with implicit str conversion + 'SIM108', # Ternary operators are not always more readable ] line-length = 80 -select = [ +lint.select = [ 'A', # flake8-builtins 'ASYNC', # flake8 async checker 'B', # flake8-bugbear @@ -56,20 +61,40 @@ select = [ 'UP', # pyupgrade ] -[per-file-ignores] +[lint.per-file-ignores] 'tests/*' = ['INP001', 'T201', 'T203'] 'examples.py' = ['T201'] -[pydocstyle] +[lint.pydocstyle] convention = 'google' -ignore-decorators = ['typing.overload'] +ignore-decorators = [ + 'typing.overload', + 'typing.override', +] -[isort] +[lint.isort] case-sensitive = true combine-as-imports = true force-wrap-aliases = true -[flake8-quotes] +[lint.flake8-quotes] docstring-quotes = 'single' inline-quotes = 'single' multiline-quotes = 'single' + +[format] +line-ending = 'lf' +indent-style = 'space' +quote-style = 'single' +docstring-code-format = true +skip-magic-trailing-comma = false +exclude = [ + '__init__.py', +] + +[lint.pycodestyle] +max-line-length = 79 + +[lint.flake8-pytest-style] +mark-parentheses = true + diff --git a/tests/test_monitor_progress.py b/tests/test_monitor_progress.py index e49e4d4..0769391 100644 --- a/tests/test_monitor_progress.py +++ b/tests/test_monitor_progress.py @@ -80,20 +80,18 @@ def test_list_example(testdir): line.rstrip() for line in _non_empty_lines(result.stderr.lines) ] pprint.pprint(result.stderr.lines, width=70) - result.stderr.fnmatch_lines( - [ - ' 0% (0 of 9) | | Elapsed Time: ?:00:00 ETA: --:--:--', - ' 11% (1 of 9) |# | Elapsed Time: ?:00:01 ETA: ?:00:08', - ' 22% (2 of 9) |## | Elapsed Time: ?:00:02 ETA: ?:00:07', - ' 33% (3 of 9) |#### | Elapsed Time: ?:00:03 ETA: ?:00:06', - ' 44% (4 of 9) |##### | Elapsed Time: ?:00:04 ETA: ?:00:05', - ' 55% (5 of 9) |###### | Elapsed Time: ?:00:05 ETA: ?:00:04', - ' 66% (6 of 9) |######## | Elapsed Time: ?:00:06 ETA: ?:00:03', - ' 77% (7 of 9) |######### | Elapsed Time: ?:00:07 ETA: ?:00:02', - ' 88% (8 of 9) |########## | Elapsed Time: ?:00:08 ETA: ?:00:01', - '100% (9 of 9) |############| Elapsed Time: ?:00:09 Time: ?:00:09', - ], - ) + result.stderr.fnmatch_lines([ + ' 0% (0 of 9) | | Elapsed Time: ?:00:00 ETA: --:--:--', + ' 11% (1 of 9) |# | Elapsed Time: ?:00:01 ETA: ?:00:08', + ' 22% (2 of 9) |## | Elapsed Time: ?:00:02 ETA: ?:00:07', + ' 33% (3 of 9) |#### | Elapsed Time: ?:00:03 ETA: ?:00:06', + ' 44% (4 of 9) |##### | Elapsed Time: ?:00:04 ETA: ?:00:05', + ' 55% (5 of 9) |###### | Elapsed Time: ?:00:05 ETA: ?:00:04', + ' 66% (6 of 9) |######## | Elapsed Time: ?:00:06 ETA: ?:00:03', + ' 77% (7 of 9) |######### | Elapsed Time: ?:00:07 ETA: ?:00:02', + ' 88% (8 of 9) |########## | Elapsed Time: ?:00:08 ETA: ?:00:01', + '100% (9 of 9) |############| Elapsed Time: ?:00:09 Time: ?:00:09', + ]) def test_generator_example(testdir): diff --git a/tests/test_progressbar_command.py b/tests/test_progressbar_command.py index 8cce715..05a3ab0 100644 --- a/tests/test_progressbar_command.py +++ b/tests/test_progressbar_command.py @@ -1,9 +1,7 @@ import io -import os.path - -import pytest import progressbar.__main__ as main +import pytest def test_size_to_bytes(): @@ -57,8 +55,6 @@ def test_main_binary(capsys): captured = capsys.readouterr() assert 'test_main(capsys):' in captured.out - # TODO: Capture the output and check that it is correct - # assert '' in captured.err def test_main_lines(capsys): @@ -70,8 +66,6 @@ def test_main_lines(capsys): captured = capsys.readouterr() assert 'test_main(capsys):' in captured.out - # TODO: Capture the output and check that it is correct - # assert '' in captured.err class Input(io.StringIO): From 0046fdf0ec79e42ed9a07ecd1fb2e80d311b885c Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Fri, 23 Feb 2024 15:42:48 +0100 Subject: [PATCH 11/17] Fixed several typing issues and hopefully some more windows bugs --- examples.py | 2 +- progressbar/__main__.py | 57 ++++++++++------ progressbar/bar.py | 70 ++++++++++++-------- progressbar/env.py | 6 +- progressbar/terminal/os_specific/__init__.py | 8 +-- progressbar/terminal/os_specific/windows.py | 4 +- 6 files changed, 90 insertions(+), 57 deletions(-) diff --git a/examples.py b/examples.py index 55c98da..c7402fa 100644 --- a/examples.py +++ b/examples.py @@ -61,7 +61,7 @@ def do_something(bar): # Sleep up to 0.1 seconds time.sleep(random.random() * 0.1) - with (progressbar.MultiBar() as multibar): + with progressbar.MultiBar() as multibar: bar_labels = [] for i in range(BARS): # Get a progressbar diff --git a/progressbar/__main__.py b/progressbar/__main__.py index 1d91f90..431aa31 100644 --- a/progressbar/__main__.py +++ b/progressbar/__main__.py @@ -4,7 +4,9 @@ import contextlib import pathlib import sys -from typing import BinaryIO +import typing +from pathlib import Path +from typing import BinaryIO, TextIO import progressbar @@ -52,7 +54,7 @@ def size_to_bytes(size_str: str) -> int: size_str = size_str[:-1] # Convert the size_str to an integer and apply the exponent - return int(size_str) * (1024 ** exponent) + return int(size_str) * (1024**exponent) def create_argument_parser() -> argparse.ArgumentParser: @@ -63,7 +65,7 @@ def create_argument_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=''' Monitor the progress of data through a pipe. - + Note that this is a Python implementation of the original `pv` command that is functional but not yet feature complete. ''' @@ -270,28 +272,26 @@ def create_argument_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None): # noqa: C901 ''' Main function for the `progressbar` command. - ''' - parser = create_argument_parser() - args = parser.parse_args(argv) - binary_mode = '' if args.line_mode else 'b' + Args: + argv (list[str] | None): Command-line arguments passed to the script. + + Returns: + None + ''' + parser: argparse.ArgumentParser = create_argument_parser() + args: argparse.Namespace = parser.parse_args(argv) with contextlib.ExitStack() as stack: - if args.output and args.output != '-': - output_stream = stack.enter_context( - open(args.output, 'w' + binary_mode) - ) - else: - if args.line_mode: - output_stream = sys.stdout - else: - output_stream = sys.stdout.buffer + output_stream: typing.IO[typing.Any] = _get_output_stream( + args.output, args.line_mode, stack + ) - input_paths = [] - total_size = 0 - filesize_available = True + input_paths: list[BinaryIO | TextIO | Path] = [] + total_size: int = 0 + filesize_available: bool = True for filename in args.input: - input_path: BinaryIO | pathlib.Path + input_path: typing.IO[typing.Any] | pathlib.Path if filename == '-': if args.line_mode: input_path = sys.stdin @@ -356,12 +356,13 @@ def main(argv: list[str] | None = None): # noqa: C901 for input_path in input_paths: if isinstance(input_path, pathlib.Path): input_stream = stack.enter_context( - input_path.open('r' + binary_mode) + input_path.open('r' if args.line_mode else 'rb') ) else: input_stream = input_path while True: + data: str | bytes if args.line_mode: data = input_stream.readline(buffer_size) else: @@ -377,5 +378,19 @@ def main(argv: list[str] | None = None): # noqa: C901 bar.finish(dirty=True) +def _get_output_stream( + output: str | None, + line_mode: bool, + stack: contextlib.ExitStack, +) -> typing.IO[typing.Any]: + if output and output != '-': + mode = 'w' if line_mode else 'wb' + return stack.enter_context(open(output, mode)) # noqa: SIM115 + elif line_mode: + return sys.stdout + else: + return sys.stdout.buffer + + if __name__ == '__main__': main() diff --git a/progressbar/bar.py b/progressbar/bar.py index e816498..e4c972c 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -162,13 +162,13 @@ class DefaultFdMixin(ProgressBarMixinBase): #: Set the terminal to be ANSI compatible. If a terminal is ANSI #: compatible we will automatically enable `colors` and disable #: `line_breaks`. - is_ansi_terminal: bool = False + is_ansi_terminal: bool | None = False #: Whether the file descriptor is a terminal or not. This is used to #: determine whether to use ANSI escape codes or not. - is_terminal: bool + is_terminal: bool | None #: Whether to print line breaks. This is useful for logging the #: progressbar. When disabled the current line is overwritten. - line_breaks: bool = True + line_breaks: bool | None = True #: Specify the type and number of colors to support. Defaults to auto #: detection based on the file descriptor type (i.e. interactive terminal) #: environment variables such as `COLORTERM` and `TERM`. Color output can @@ -179,9 +179,7 @@ class DefaultFdMixin(ProgressBarMixinBase): #: For true (24 bit/16M) color support you can use `COLORTERM=truecolor`. #: For 256 color support you can use `TERM=xterm-256color`. #: For 16 colorsupport you can use `TERM=xterm`. - enable_colors: progressbar.env.ColorSupport | bool | None = ( - progressbar.env.COLOR_SUPPORT - ) + enable_colors: progressbar.env.ColorSupport = progressbar.env.COLOR_SUPPORT def __init__( self, @@ -200,7 +198,7 @@ def __init__( fd = self._apply_line_offset(fd, line_offset) self.fd = fd self.is_ansi_terminal = progressbar.env.is_ansi_terminal(fd) - self.is_terminal = self._determine_is_terminal(fd, is_terminal) + self.is_terminal = progressbar.env.is_terminal(fd, is_terminal) self.line_breaks = self._determine_line_breaks(line_breaks) self.enable_colors = self._determine_enable_colors(enable_colors) @@ -219,29 +217,47 @@ def _apply_line_offset( else: return fd - def _determine_is_terminal( - self, - fd: base.TextIO, - is_terminal: bool | None, - ) -> bool: - if is_terminal is not None: - return progressbar.env.is_terminal(fd, is_terminal) - else: - return progressbar.env.is_ansi_terminal(fd) - - def _determine_line_breaks(self, line_breaks: bool | None) -> bool: + def _determine_line_breaks(self, line_breaks: bool | None) -> bool | None: if line_breaks is None: return progressbar.env.env_flag( 'PROGRESSBAR_LINE_BREAKS', not self.is_terminal, ) else: - return bool(line_breaks) + return line_breaks def _determine_enable_colors( self, enable_colors: progressbar.env.ColorSupport | None, ) -> progressbar.env.ColorSupport: + ''' + Determines the color support for the progress bar. + + This method checks the `enable_colors` parameter and the environment + variables `PROGRESSBAR_ENABLE_COLORS` and `FORCE_COLOR` to determine + the color support. + + If `enable_colors` is: + - `None`, it checks the environment variables and the terminal + compatibility to ANSI. + - `True`, it sets the color support to XTERM_256. + - `False`, it sets the color support to NONE. + - For different values that are not instances of + `progressbar.env.ColorSupport`, it raises a ValueError. + + Args: + enable_colors (progressbar.env.ColorSupport | None): The color + support setting from the user. It can be None, True, False, + or an instance of `progressbar.env.ColorSupport`. + + Returns: + progressbar.env.ColorSupport: The determined color support. + + Raises: + ValueError: If `enable_colors` is not None, True, False, or an + instance of `progressbar.env.ColorSupport`. + ''' + color_support = progressbar.env.ColorSupport.NONE if enable_colors is None: colors = ( progressbar.env.env_flag('PROGRESSBAR_ENABLE_COLORS'), @@ -252,21 +268,23 @@ def _determine_enable_colors( for color_enabled in colors: if color_enabled is not None: if color_enabled: - enable = progressbar.env.COLOR_SUPPORT + color_support = progressbar.env.COLOR_SUPPORT else: - enable = progressbar.env.ColorSupport.NONE + color_support = progressbar.env.ColorSupport.NONE break else: - enable = False + color_support = progressbar.env.ColorSupport.NONE elif enable_colors is True: - enable = progressbar.env.ColorSupport.XTERM_256 + color_support = progressbar.env.ColorSupport.XTERM_256 elif enable_colors is False: - enable = progressbar.env.ColorSupport.NONE - elif not isinstance(enable_colors, progressbar.env.ColorSupport): + color_support = progressbar.env.ColorSupport.NONE + elif isinstance(enable_colors, progressbar.env.ColorSupport): + color_support = enable_colors + else: raise ValueError(f'Invalid color support value: {enable_colors}') - return enable + return color_support def print(self, *args: types.Any, **kwargs: types.Any) -> None: print(*args, file=self.fd, **kwargs) diff --git a/progressbar/env.py b/progressbar/env.py index 2a4e175..634767b 100644 --- a/progressbar/env.py +++ b/progressbar/env.py @@ -81,7 +81,7 @@ def from_env(cls): ): return cls.XTERM_TRUECOLOR else: - return cls.WINDOWS + return cls.WINDOWS # pragma: no cover support = cls.NONE for variable in variables: @@ -142,7 +142,7 @@ def is_ansi_terminal( return is_terminal -def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool: +def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool | None: if is_terminal is None: # Full ansi support encompasses what we expect from a terminal is_terminal = is_ansi_terminal(fd) or None @@ -159,7 +159,7 @@ def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool: except Exception: is_terminal = False - return bool(is_terminal) + return is_terminal # Enable Windows full color mode if possible diff --git a/progressbar/terminal/os_specific/__init__.py b/progressbar/terminal/os_specific/__init__.py index 08c9a80..4a297e6 100644 --- a/progressbar/terminal/os_specific/__init__.py +++ b/progressbar/terminal/os_specific/__init__.py @@ -11,13 +11,13 @@ else: from .posix import getch as _getch - def _reset_console_mode(): + def _reset_console_mode() -> None: pass - def _set_console_mode(): - pass + def _set_console_mode() -> bool: + return False - def _get_console_mode(): + def _get_console_mode() -> int: return 0 diff --git a/progressbar/terminal/os_specific/windows.py b/progressbar/terminal/os_specific/windows.py index 05f8b69..425d349 100644 --- a/progressbar/terminal/os_specific/windows.py +++ b/progressbar/terminal/os_specific/windows.py @@ -120,7 +120,7 @@ class _Event(ctypes.Union): _fields_ = (('EventType', _WORD), ('Event', _Event)) -def reset_console_mode(): +def reset_console_mode() -> None: _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(_input_mode.value)) _SetConsoleMode(_HANDLE(_h_console_output), _DWORD(_output_mode.value)) @@ -144,7 +144,7 @@ def get_console_mode() -> int: return _input_mode.value -def set_text_color(color): +def set_text_color(color) -> None: _kernel32.SetConsoleTextAttribute(_h_console_output, color) From 29fca6da243ebc39be5167d4743be804927baa66 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 25 Feb 2024 08:17:05 +0100 Subject: [PATCH 12/17] fixed several tests, windows issues and type hinting problems --- docs/_theme/flask_theme_support.py | 14 +- docs/conf.py | 7 +- examples.py | 161 +++++++++---------- progressbar/bar.py | 116 ++++++------- progressbar/env.py | 25 ++- progressbar/terminal/os_specific/__init__.py | 8 +- progressbar/terminal/os_specific/posix.py | 6 +- ruff.toml | 4 +- tests/test_color.py | 70 ++++++-- tests/test_failure.py | 4 +- tests/test_progressbar.py | 6 + tests/test_utils.py | 12 +- tests/test_windows.py | 16 +- 13 files changed, 257 insertions(+), 192 deletions(-) diff --git a/docs/_theme/flask_theme_support.py b/docs/_theme/flask_theme_support.py index c11997c..8174712 100644 --- a/docs/_theme/flask_theme_support.py +++ b/docs/_theme/flask_theme_support.py @@ -1,18 +1,18 @@ # flasky extensions. flasky pygments style based on tango style from pygments.style import Style from pygments.token import ( - Keyword, - Name, Comment, - String, Error, + Generic, + Keyword, + Literal, + Name, Number, Operator, - Generic, - Whitespace, - Punctuation, Other, - Literal, + Punctuation, + String, + Whitespace, ) diff --git a/docs/conf.py b/docs/conf.py index 8912b99..c4ed327 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Progress Bar documentation build configuration file, created by # sphinx-quickstart on Tue Aug 20 11:47:33 2013. @@ -11,16 +10,16 @@ # All configuration values have a default; values that are commented out # serve to show the default. +import datetime import os import sys -import datetime # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) -from progressbar import __about__ as metadata # noqa: E402 +from progressbar import __about__ as metadata # -- General configuration ----------------------------------------------- @@ -59,7 +58,7 @@ master_doc = 'index' # General information about the project. -project = u'Progress Bar' +project = 'Progress Bar' project_slug = ''.join(project.capitalize().split()) copyright = f'{datetime.date.today().year}, {metadata.__author__}' diff --git a/examples.py b/examples.py index c7402fa..00e565e 100644 --- a/examples.py +++ b/examples.py @@ -1,16 +1,17 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- +from __future__ import annotations +import contextlib import functools +import os import random import sys -import threading import time import typing import progressbar -examples: typing.List[typing.Callable[[typing.Any], typing.Any]] = [] +examples: list[typing.Callable[[typing.Any], typing.Any]] = [] def example(fn): @@ -41,23 +42,28 @@ def fast_example(): @example def shortcut_example(): - for i in progressbar.progressbar(range(10)): + for _ in progressbar.progressbar(range(10)): time.sleep(0.1) @example def prefixed_shortcut_example(): - for i in progressbar.progressbar(range(10), prefix='Hi: '): + for _ in progressbar.progressbar(range(10), prefix='Hi: '): time.sleep(0.1) @example def parallel_bars_multibar_example(): + if os.name == 'nt': + print('Skipping multibar example on Windows due to threading ' + 'incompatibilities with the example code.') + return + BARS = 5 N = 50 def do_something(bar): - for i in bar(range(N)): + for _ in bar(range(N)): # Sleep up to 0.1 seconds time.sleep(random.random() * 0.1) @@ -67,10 +73,9 @@ def do_something(bar): # Get a progressbar bar_label = 'Bar #%d' % i bar_labels.append(bar_label) - bar = multibar[bar_label] - - for i in range(N * BARS): + multibar[bar_label] + for _ in range(N * BARS): time.sleep(0.005) bar_i = random.randrange(0, BARS) @@ -78,29 +83,27 @@ def do_something(bar): # Increment one of the progress bars at random multibar[bar_label].increment() + @example def multiple_bars_line_offset_example(): BARS = 5 N = 100 - # Construct the list of progress bars with the `line_offset` so they draw - # below each other - bars = [] - for i in range(BARS): - bars.append( - progressbar.ProgressBar( - max_value=N, - # We add 1 to the line offset to account for the `print_fd` - line_offset=i + 1, - max_error=False, - ) + bars = [ + progressbar.ProgressBar( + max_value=N, + # We add 1 to the line offset to account for the `print_fd` + line_offset=i + 1, + max_error=False, ) - + for i in range(BARS) + ] # Create a file descriptor for regular printing as well print_fd = progressbar.LineOffsetStreamWrapper(lines=0, stream=sys.stdout) + assert print_fd # The progress bar updates, normally you would do something useful here - for i in range(N * BARS): + for _ in range(N * BARS): time.sleep(0.005) # Increment one of the progress bars at random @@ -115,7 +118,7 @@ def multiple_bars_line_offset_example(): @example def templated_shortcut_example(): - for i in progressbar.progressbar(range(10), suffix='{seconds_elapsed:.1}'): + for _ in progressbar.progressbar(range(10), suffix='{seconds_elapsed:.1}'): time.sleep(0.1) @@ -125,7 +128,7 @@ def job_status_example(): redirect_stdout=True, widgets=[progressbar.widgets.JobStatusBar('status')], ) as bar: - for i in range(30): + for _ in range(30): print('random', random.random()) # Roughly 1/3 probability for each status ;) # Yes... probability is confusing at times @@ -204,7 +207,7 @@ def multi_range_bar_example(): '\x1b[31m.\x1b[39m', # Scheduling ' ', # Not started ] - widgets = [progressbar.MultiRangeBar("amounts", markers=markers)] + widgets = [progressbar.MultiRangeBar('amounts', markers=markers)] amounts = [0] * (len(markers) - 1) + [25] with progressbar.ProgressBar(widgets=widgets, max_value=10).start() as bar: @@ -212,7 +215,7 @@ def multi_range_bar_example(): incomplete_items = [ idx for idx, amount in enumerate(amounts) - for i in range(amount) + for _ in range(amount) if idx != 0 ] if not incomplete_items: @@ -230,7 +233,7 @@ 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 + for _ in range(25) # 25 jobs total ] widgets = [ @@ -260,17 +263,17 @@ def multi_progress_bar_example(left=True): @example def granular_progress_example(): widgets = [ - progressbar.GranularBar(markers=" ▏▎▍▌▋▊▉█", left='', right='|'), - progressbar.GranularBar(markers=" ▁▂▃▄▅▆▇█", left='', right='|'), - progressbar.GranularBar(markers=" ▖▌▛█", left='', right='|'), - progressbar.GranularBar(markers=" ░▒▓█", left='', right='|'), - progressbar.GranularBar(markers=" ⡀⡄⡆⡇⣇⣧⣷⣿", left='', right='|'), - progressbar.GranularBar(markers=" .oO", left='', right=''), + progressbar.GranularBar(markers=' ▏▎▍▌▋▊▉█', left='', right='|'), + progressbar.GranularBar(markers=' ▁▂▃▄▅▆▇█', left='', right='|'), + progressbar.GranularBar(markers=' ▖▌▛█', left='', right='|'), + progressbar.GranularBar(markers=' ░▒▓█', left='', right='|'), + progressbar.GranularBar(markers=' ⡀⡄⡆⡇⣇⣧⣷⣿', left='', right='|'), + progressbar.GranularBar(markers=' .oO', left='', right=''), ] - for i in progressbar.progressbar(list(range(100)), widgets=widgets): + for _ in progressbar.progressbar(list(range(100)), widgets=widgets): time.sleep(0.03) - for i in progressbar.progressbar(iter(range(100)), widgets=widgets): + for _ in progressbar.progressbar(iter(range(100)), widgets=widgets): time.sleep(0.03) @@ -300,6 +303,7 @@ def file_transfer_example(): bar = progressbar.ProgressBar(widgets=widgets, max_value=1000).start() for i in range(100): # do something + time.sleep(0.01) bar.update(10 * i + 1) bar.finish() @@ -333,6 +337,7 @@ def update(self, bar): bar.start() for i in range(200): # do something + time.sleep(0.01) bar.update(5 * i + 1) bar.finish() @@ -349,8 +354,8 @@ def double_bar_example(): bar = progressbar.ProgressBar(widgets=widgets, max_value=1000).start() for i in range(100): # do something - bar.update(10 * i + 1) time.sleep(0.01) + bar.update(10 * i + 1) bar.finish() @@ -400,7 +405,7 @@ def basic_progress(): def progress_with_automatic_max(): # Progressbar can guess max_value automatically. bar = progressbar.ProgressBar() - for i in bar(range(8)): + for _ in bar(range(8)): time.sleep(0.1) @@ -408,7 +413,7 @@ def progress_with_automatic_max(): def progress_with_unavailable_max(): # Progressbar can't guess max_value. bar = progressbar.ProgressBar(max_value=8) - for i in bar((i for i in range(8))): + for _ in bar(i for i in range(8)): time.sleep(0.1) @@ -417,7 +422,7 @@ def animated_marker(): bar = progressbar.ProgressBar( widgets=['Working: ', progressbar.AnimatedMarker()] ) - for i in bar((i for i in range(5))): + for _ in bar(i for i in range(5)): time.sleep(0.1) @@ -430,7 +435,7 @@ def filling_bar_animated_marker(): ), ] ) - for i in bar(range(15)): + for _ in bar(range(15)): time.sleep(0.1) @@ -444,7 +449,7 @@ def counter_and_timer(): ')', ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(15))): + for _ in bar(i for i in range(15)): time.sleep(0.1) @@ -454,7 +459,7 @@ def format_label(): progressbar.FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)') ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(15))): + for _ in bar(i for i in range(15)): time.sleep(0.1) @@ -462,7 +467,7 @@ def format_label(): def animated_balloons(): widgets = ['Balloon: ', progressbar.AnimatedMarker(markers='.oO@* ')] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(24))): + for _ in bar(i for i in range(24)): time.sleep(0.1) @@ -472,7 +477,7 @@ def animated_arrows(): try: widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='←↖↑↗→↘↓↙')] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(24))): + for _ in bar(i for i in range(24)): time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @@ -484,7 +489,7 @@ def animated_filled_arrows(): try: widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='◢◣◤◥')] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(24))): + for _ in bar(i for i in range(24)): time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @@ -496,7 +501,7 @@ def animated_wheels(): try: widgets = ['Wheels: ', progressbar.AnimatedMarker(markers='◐◓◑◒')] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(24))): + for _ in bar(i for i in range(24)): time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @@ -509,7 +514,7 @@ def format_label_bouncer(): progressbar.BouncingBar(), ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(100))): + for _ in bar(i for i in range(100)): time.sleep(0.01) @@ -521,14 +526,14 @@ def format_label_rotating_bouncer(): ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(18))): + for _ in bar(i for i in range(18)): time.sleep(0.1) @example def with_right_justify(): with progressbar.ProgressBar( - max_value=10, term_width=20, left_justify=False + max_value=10, term_width=20, left_justify=False ) as progress: assert progress.term_width is not None for i in range(10): @@ -537,20 +542,16 @@ def with_right_justify(): @example def exceeding_maximum(): - with progressbar.ProgressBar(max_value=1) as progress: - try: - progress.update(2) - except ValueError: - pass + with progressbar.ProgressBar(max_value=1) as progress, contextlib.suppress( + ValueError): + progress.update(2) @example def reaching_maximum(): progress = progressbar.ProgressBar(max_value=1) - try: + with contextlib.suppress(RuntimeError): progress.update(1) - except RuntimeError: - pass @example @@ -567,20 +568,11 @@ def stderr_redirection(): progress.update(0) -@example -def negative_maximum(): - try: - with progressbar.ProgressBar(max_value=-1) as progress: - progress.start() - except ValueError: - pass - - @example def rotating_bouncing_marker(): widgets = [progressbar.BouncingBar(marker=progressbar.RotatingMarker())] with progressbar.ProgressBar( - widgets=widgets, max_value=20, term_width=10 + widgets=widgets, max_value=20, term_width=10 ) as progress: for i in range(20): time.sleep(0.1) @@ -592,7 +584,7 @@ def rotating_bouncing_marker(): ) ] with progressbar.ProgressBar( - widgets=widgets, max_value=20, term_width=10 + widgets=widgets, max_value=20, term_width=10 ) as progress: for i in range(20): time.sleep(0.1) @@ -608,7 +600,7 @@ def incrementing_bar(): ], max_value=10, ).start() - for i in range(10): + for _ in range(10): # do something time.sleep(0.1) bar += 1 @@ -684,7 +676,7 @@ def adaptive_eta_without_value_change(): poll_interval=0.0001, ) bar.start() - for i in range(100): + for _ in range(100): bar.update(1) time.sleep(0.1) bar.finish() @@ -695,9 +687,9 @@ def iterator_with_max_value(): # Testing using progressbar as an iterator with a max value bar = progressbar.ProgressBar() - for n in bar(iter(range(100)), 100): + for _ in bar(iter(range(100)), 100): # iter range is a way to get an iterator in both python 2 and 3 - pass + time.sleep(0.01) @example @@ -765,13 +757,13 @@ def user_variables(): num_subtasks = sum(len(x) for x in tasks.values()) with progressbar.ProgressBar( - prefix='{variables.task} >> {variables.subtask}', - variables={'task': '--', 'subtask': '--'}, - max_value=10 * num_subtasks, + prefix='{variables.task} >> {variables.subtask}', + variables={'task': '--', 'subtask': '--'}, + max_value=10 * num_subtasks, ) as bar: for tasks_name, subtasks in tasks.items(): for subtask_name in subtasks: - for i in range(10): + for _ in range(10): bar.update( bar.value + 1, task=tasks_name, subtask=subtask_name ) @@ -803,14 +795,14 @@ def format_custom_text(): @example def simple_api_example(): bar = progressbar.ProgressBar(widget_kwargs=dict(fill='█')) - for i in bar(range(200)): + for _ in bar(range(200)): time.sleep(0.02) @example -def ETA_on_generators(): +def eta_on_generators(): def gen(): - for x in range(200): + for _ in range(200): yield None widgets = [ @@ -822,14 +814,14 @@ def gen(): ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar(gen()): + for _ in bar(gen()): time.sleep(0.02) @example def percentage_on_generators(): def gen(): - for x in range(200): + for _ in range(200): yield None widgets = [ @@ -842,19 +834,22 @@ def gen(): ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar(gen()): + for _ in bar(gen()): time.sleep(0.02) def test(*tests): if tests: + no_tests = True for example in examples: for test in tests: if test in example.__name__: example() + no_tests = False break - else: + if no_tests: + for example in examples: print('Skipping', example.__name__) else: for example in examples: diff --git a/progressbar/bar.py b/progressbar/bar.py index e4c972c..8b859b2 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -127,7 +127,12 @@ def finish(self): # pragma: no cover def __del__(self): if not self._finished and self._started: # pragma: no cover - self.finish() + # We're not using contextlib.suppress here because during teardown + # contextlib is not available anymore. + try: # noqa: SIM105 + self.finish() + except AttributeError: + pass def __getstate__(self): return self.__dict__ @@ -182,13 +187,13 @@ class DefaultFdMixin(ProgressBarMixinBase): enable_colors: progressbar.env.ColorSupport = progressbar.env.COLOR_SUPPORT def __init__( - self, - fd: base.TextIO = sys.stderr, - is_terminal: bool | None = None, - line_breaks: bool | None = None, - enable_colors: progressbar.env.ColorSupport | None = None, - line_offset: int = 0, - **kwargs, + self, + fd: base.TextIO = sys.stderr, + is_terminal: bool | None = None, + line_breaks: bool | None = None, + enable_colors: progressbar.env.ColorSupport | None = None, + line_offset: int = 0, + **kwargs, ): if fd is sys.stdout: fd = utils.streams.original_stdout @@ -205,9 +210,9 @@ def __init__( super().__init__(**kwargs) def _apply_line_offset( - self, - fd: base.TextIO, - line_offset: int, + self, + fd: base.TextIO, + line_offset: int, ) -> base.TextIO: if line_offset: return progressbar.terminal.stream.LineOffsetStreamWrapper( @@ -227,8 +232,8 @@ def _determine_line_breaks(self, line_breaks: bool | None) -> bool | None: return line_breaks def _determine_enable_colors( - self, - enable_colors: progressbar.env.ColorSupport | None, + self, + enable_colors: progressbar.env.ColorSupport | None, ) -> progressbar.env.ColorSupport: ''' Determines the color support for the progress bar. @@ -257,7 +262,7 @@ def _determine_enable_colors( ValueError: If `enable_colors` is not None, True, False, or an instance of `progressbar.env.ColorSupport`. ''' - color_support = progressbar.env.ColorSupport.NONE + color_support: progressbar.env.ColorSupport if enable_colors is None: colors = ( progressbar.env.env_flag('PROGRESSBAR_ENABLE_COLORS'), @@ -304,9 +309,9 @@ def update(self, *args: types.Any, **kwargs: types.Any) -> None: self.fd.write(types.cast(str, line.encode('ascii', 'replace'))) def finish( - self, - *args: types.Any, - **kwargs: types.Any, + self, + *args: types.Any, + **kwargs: types.Any, ) -> None: # pragma: no cover if self._finished: return @@ -336,8 +341,8 @@ def _format_widgets(self): for index, widget in enumerate(self.widgets): if isinstance( - widget, - widgets.WidgetBase, + widget, + widgets.WidgetBase, ) and not widget.check_size(self): continue elif isinstance(widget, widgets.AutoWidthWidgetBase): @@ -382,8 +387,10 @@ def __init__(self, term_width: int | None = None, **kwargs): self._handle_resize() import signal - self._prev_handle = signal.getsignal(signal.SIGWINCH) - signal.signal(signal.SIGWINCH, self._handle_resize) + self._prev_handle = signal.getsignal( + signal.SIGWINCH) # type: ignore + signal.signal(signal.SIGWINCH, # type: ignore + self._handle_resize) self.signal_set = True def _handle_resize(self, signum=None, frame=None): @@ -397,7 +404,8 @@ def finish(self): # pragma: no cover with contextlib.suppress(Exception): import signal - signal.signal(signal.SIGWINCH, self._prev_handle) + signal.signal(signal.SIGWINCH, # type: ignore + self._prev_handle) class StdRedirectMixin(DefaultFdMixin): @@ -409,10 +417,10 @@ class StdRedirectMixin(DefaultFdMixin): _stderr: base.IO def __init__( - self, - redirect_stderr: bool = False, - redirect_stdout: bool = False, - **kwargs, + self, + redirect_stderr: bool = False, + redirect_stdout: bool = False, + **kwargs, ): DefaultFdMixin.__init__(self, **kwargs) self.redirect_stderr = redirect_stderr @@ -540,23 +548,23 @@ class ProgressBar( paused: bool = False def __init__( - self, - min_value: NumberT = 0, - max_value: NumberT | types.Type[base.UnknownLength] | None = None, - widgets: types.Optional[ - types.Sequence[widgets_module.WidgetBase | str] - ] = None, - left_justify: bool = True, - initial_value: NumberT = 0, - poll_interval: types.Optional[float] = None, - widget_kwargs: types.Optional[types.Dict[str, types.Any]] = None, - custom_len: types.Callable[[str], int] = utils.len_color, - max_error=True, - prefix=None, - suffix=None, - variables=None, - min_poll_interval=None, - **kwargs, + self, + min_value: NumberT = 0, + max_value: NumberT | types.Type[base.UnknownLength] | None = None, + widgets: types.Optional[ + types.Sequence[widgets_module.WidgetBase | str] + ] = None, + left_justify: bool = True, + initial_value: NumberT = 0, + poll_interval: types.Optional[float] = None, + widget_kwargs: types.Optional[types.Dict[str, types.Any]] = None, + custom_len: types.Callable[[str], int] = utils.len_color, + max_error=True, + prefix=None, + suffix=None, + variables=None, + min_poll_interval=None, + **kwargs, ): # sourcery skip: low-code-quality '''Initializes a progress bar with sane defaults.''' StdRedirectMixin.__init__(self, **kwargs) @@ -621,8 +629,8 @@ def __init__( default=None, ) self._MINIMUM_UPDATE_INTERVAL = ( - utils.deltas_to_seconds(self._MINIMUM_UPDATE_INTERVAL) - or self._MINIMUM_UPDATE_INTERVAL + utils.deltas_to_seconds(self._MINIMUM_UPDATE_INTERVAL) + or self._MINIMUM_UPDATE_INTERVAL ) # Note that the _MINIMUM_UPDATE_INTERVAL sets the minimum in case of @@ -638,8 +646,8 @@ def __init__( self.variables = utils.AttributeDict(variables or {}) for widget in self.widgets: if ( - isinstance(widget, widgets_module.VariableMixin) - and widget.name not in self.variables + isinstance(widget, widgets_module.VariableMixin) + and widget.name not in self.variables ): self.variables[widget.name] = None @@ -760,7 +768,7 @@ def data(self) -> types.Dict[str, types.Any]: total_seconds_elapsed=total_seconds_elapsed, # The seconds since the bar started modulo 60 seconds_elapsed=(elapsed.seconds % 60) - + (elapsed.microseconds / 1000000.0), + + (elapsed.microseconds / 1000000.0), # The minutes since the bar started modulo 60 minutes_elapsed=(elapsed.seconds / 60) % 60, # The hours since the bar started modulo 24 @@ -890,9 +898,9 @@ def update(self, value=None, force=False, **kwargs): self.start() if ( - value is not None - and value is not base.UnknownLength - and isinstance(value, int) + value is not None + and value is not base.UnknownLength + and isinstance(value, (int, float)) ): if self.max_value is base.UnknownLength: # Can't compare against unknown lengths so just update @@ -1015,9 +1023,9 @@ def _init_prefix(self): def _verify_max_value(self): if ( - self.max_value is not base.UnknownLength - and self.max_value is not None - and self.max_value < 0 # type: ignore + self.max_value is not base.UnknownLength + and self.max_value is not None + and self.max_value < 0 # type: ignore ): raise ValueError('max_value out of range, got %r' % self.max_value) diff --git a/progressbar/env.py b/progressbar/env.py index 634767b..d7990d2 100644 --- a/progressbar/env.py +++ b/progressbar/env.py @@ -51,8 +51,8 @@ def from_env(cls): will enable 256 color/8 bit support. If they contain `xterm`, we will enable 16 color support. Otherwise, we will assume no color support. - If `JUPYTER_COLUMNS` or `JUPYTER_LINES` is set, we will assume true - color support. + If `JUPYTER_COLUMNS` or `JUPYTER_LINES` or `JPY_PARENT_PID` is set, we + will assume true color support. Note that the highest available value will be used! Having `COLORTERM=truecolor` will override `TERM=xterm-256color`. @@ -64,9 +64,7 @@ def from_env(cls): 'TERM', ) - if os.environ.get('JUPYTER_COLUMNS') or os.environ.get( - 'JUPYTER_LINES', - ): + if JUPYTER: # Jupyter notebook always supports true color. return cls.XTERM_TRUECOLOR elif os.name == 'nt': @@ -76,8 +74,8 @@ def from_env(cls): from .terminal.os_specific import windows if ( - windows.get_console_mode() - & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + windows.get_console_mode() + & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT ): return cls.XTERM_TRUECOLOR else: @@ -101,18 +99,17 @@ def from_env(cls): def is_ansi_terminal( - fd: base.IO, - is_terminal: bool | None = None, + fd: base.IO, + is_terminal: bool | None = None, ) -> bool | None: # pragma: no cover if is_terminal is None: - # Jupyter Notebooks define this variable and support progress bars - if 'JPY_PARENT_PID' in os.environ: + # Jupyter Notebooks support progress bars + if JUPYTER: is_terminal = True # This works for newer versions of pycharm only. With older versions # there is no way to check. elif os.environ.get('PYCHARM_HOSTED') == '1' and not os.environ.get( - 'PYTEST_CURRENT_TEST', - ): + 'PYTEST_CURRENT_TEST'): is_terminal = True if is_terminal is None: @@ -168,6 +165,8 @@ def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool | None: os_specific.set_console_mode() +JUPYTER = bool(os.environ.get('JUPYTER_COLUMNS') or os.environ.get( + 'JUPYTER_LINES') or os.environ.get('JPY_PARENT_PID')) COLOR_SUPPORT = ColorSupport.from_env() ANSI_TERMS = ( '([xe]|bv)term', diff --git a/progressbar/terminal/os_specific/__init__.py b/progressbar/terminal/os_specific/__init__.py index 4a297e6..4fae3c3 100644 --- a/progressbar/terminal/os_specific/__init__.py +++ b/progressbar/terminal/os_specific/__init__.py @@ -1,6 +1,6 @@ -import sys +import os -if sys.platform.startswith('win'): +if os.name == 'nt': from .windows import ( get_console_mode as _get_console_mode, getch as _getch, @@ -11,16 +11,18 @@ else: from .posix import getch as _getch + def _reset_console_mode() -> None: pass + def _set_console_mode() -> bool: return False + def _get_console_mode() -> int: return 0 - getch = _getch reset_console_mode = _reset_console_mode set_console_mode = _set_console_mode diff --git a/progressbar/terminal/os_specific/posix.py b/progressbar/terminal/os_specific/posix.py index e9bd475..52a9560 100644 --- a/progressbar/terminal/os_specific/posix.py +++ b/progressbar/terminal/os_specific/posix.py @@ -5,11 +5,11 @@ def getch(): fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) + old_settings = termios.tcgetattr(fd) # type: ignore try: - tty.setraw(sys.stdin.fileno()) + tty.setraw(sys.stdin.fileno()) # type: ignore ch = sys.stdin.read(1) finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) # type: ignore return ch diff --git a/ruff.toml b/ruff.toml index 90f115b..bd1b288 100644 --- a/ruff.toml +++ b/ruff.toml @@ -63,7 +63,9 @@ lint.select = [ [lint.per-file-ignores] 'tests/*' = ['INP001', 'T201', 'T203'] -'examples.py' = ['T201'] +'examples.py' = ['T201', 'N806'] +'docs/conf.py' = ['E501', 'INP001'] +'docs/_theme/flask_theme_support.py' = ['RUF012', 'INP001'] [lint.pydocstyle] convention = 'google' diff --git a/tests/test_color.py b/tests/test_color.py index feb962e..14b5899 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -4,12 +4,30 @@ import typing import progressbar -import progressbar.env import progressbar.terminal import pytest from progressbar import env, terminal, widgets from progressbar.terminal import Colors, apply_colors, colors +ENVIRONMENT_VARIABLES = [ + 'PROGRESSBAR_ENABLE_COLORS', + 'FORCE_COLOR', + 'COLORTERM', + 'TERM', + 'JUPYTER_COLUMNS', + 'JUPYTER_LINES', + 'JPY_PARENT_PID', +] + + +@pytest.fixture(autouse=True) +def clear_env(monkeypatch: pytest.MonkeyPatch): + # Clear all environment variables that might affect the tests + for variable in ENVIRONMENT_VARIABLES: + monkeypatch.delenv(variable, raising=False) + + monkeypatch.setattr(env, 'JUPYTER', False) + @pytest.mark.parametrize( 'variable', @@ -18,18 +36,26 @@ 'FORCE_COLOR', ], ) -def test_color_environment_variables(monkeypatch, variable): +def test_color_environment_variables(monkeypatch: pytest.MonkeyPatch, + variable): + if os.name == 'nt': + # Windows has special handling so we need to disable that to make the + # tests work properly + monkeypatch.setattr(os, 'name', 'posix') + monkeypatch.setattr( env, 'COLOR_SUPPORT', - progressbar.env.ColorSupport.XTERM_256, + env.ColorSupport.XTERM_256, ) - monkeypatch.setenv(variable, '1') + monkeypatch.setenv(variable, 'true') bar = progressbar.ProgressBar() + assert not env.is_ansi_terminal(bar.fd) + assert not bar.is_ansi_terminal assert bar.enable_colors - monkeypatch.setenv(variable, '0') + monkeypatch.setenv(variable, '') bar = progressbar.ProgressBar() assert not bar.enable_colors @@ -55,11 +81,13 @@ def test_color_environment_variables(monkeypatch, variable): ], ) def test_color_support_from_env(monkeypatch, variable, value): - monkeypatch.setenv('JUPYTER_COLUMNS', '') - monkeypatch.setenv('JUPYTER_LINES', '') + if os.name == 'nt': + # Windows has special handling so we need to disable that to make the + # tests work properly + monkeypatch.setattr(os, 'name', 'posix') monkeypatch.setenv(variable, value) - progressbar.env.ColorSupport.from_env() + env.ColorSupport.from_env() @pytest.mark.parametrize( @@ -70,8 +98,15 @@ def test_color_support_from_env(monkeypatch, variable, value): ], ) def test_color_support_from_env_jupyter(monkeypatch, variable): - monkeypatch.setenv(variable, '80') - progressbar.env.ColorSupport.from_env() + monkeypatch.setattr(env, 'JUPYTER', True) + assert env.ColorSupport.from_env() == env.ColorSupport.XTERM_TRUECOLOR + + # Sanity check + monkeypatch.setattr(env, 'JUPYTER', False) + if os.name == 'nt': + assert env.ColorSupport.from_env() == env.ColorSupport.WINDOWS + else: + assert env.ColorSupport.from_env() == env.ColorSupport.NONE def test_enable_colors_flags(): @@ -82,7 +117,7 @@ def test_enable_colors_flags(): assert not bar.enable_colors bar = progressbar.ProgressBar( - enable_colors=progressbar.env.ColorSupport.XTERM_TRUECOLOR, + enable_colors=env.ColorSupport.XTERM_TRUECOLOR, ) assert bar.enable_colors @@ -167,7 +202,7 @@ def test_no_color_widgets(widget): ).uses_colors -def test_colors(): +def test_colors(monkeypatch): for colors_ in Colors.by_rgb.values(): for color in colors_: rgb = color.rgb @@ -176,11 +211,18 @@ def test_colors(): assert rgb.to_ansi_16 is not None assert rgb.to_ansi_256 is not None assert rgb.to_windows is not None - assert color.underline + + with monkeypatch.context() as context: + context.setattr(env,'COLOR_SUPPORT', env.ColorSupport.XTERM) + assert color.underline + context.setattr(env,'COLOR_SUPPORT', env.ColorSupport.WINDOWS) + assert color.underline + assert color.fg assert color.bg assert str(color) assert str(rgb) + assert color('test') def test_color(): @@ -290,7 +332,7 @@ def test_apply_colors(text, fg, bg, fg_none, bg_none, percentage, expected, monkeypatch.setattr( env, 'COLOR_SUPPORT', - progressbar.env.ColorSupport.XTERM_256, + env.ColorSupport.XTERM_256, ) assert ( apply_colors( diff --git a/tests/test_failure.py b/tests/test_failure.py index cee84b7..4c10546 100644 --- a/tests/test_failure.py +++ b/tests/test_failure.py @@ -1,10 +1,12 @@ +import logging import time import progressbar import pytest -def test_missing_format_values(): +def test_missing_format_values(caplog): + caplog.set_level(logging.CRITICAL, logger='progressbar.widgets') with pytest.raises(KeyError): p = progressbar.ProgressBar( widgets=[progressbar.widgets.FormatLabel('%(x)s')], diff --git a/tests/test_progressbar.py b/tests/test_progressbar.py index d418d4c..d329424 100644 --- a/tests/test_progressbar.py +++ b/tests/test_progressbar.py @@ -68,3 +68,9 @@ def test_dirty(): bar.finish(dirty=True) assert bar.finished() assert bar.started() + + +def test_negative_maximum(): + with pytest.raises(ValueError), progressbar.ProgressBar( + max_value=-1) as progress: + progress.start() diff --git a/tests/test_utils.py b/tests/test_utils.py index 448a8c8..8003204 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -38,17 +38,17 @@ def test_is_terminal(monkeypatch): fd = io.StringIO() monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL', raising=False) - monkeypatch.delenv('JPY_PARENT_PID', raising=False) + monkeypatch.setattr(progressbar.env, 'JUPYTER', False) assert progressbar.env.is_terminal(fd) is False assert progressbar.env.is_terminal(fd, True) is True assert progressbar.env.is_terminal(fd, False) is False - monkeypatch.setenv('JPY_PARENT_PID', '123') + monkeypatch.setattr(progressbar.env, 'JUPYTER', True) assert progressbar.env.is_terminal(fd) is True - monkeypatch.delenv('JPY_PARENT_PID') # Sanity check + monkeypatch.setattr(progressbar.env, 'JUPYTER', False) assert progressbar.env.is_terminal(fd) is False monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'true') @@ -65,15 +65,15 @@ def test_is_ansi_terminal(monkeypatch): fd = io.StringIO() monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL', raising=False) - monkeypatch.delenv('JPY_PARENT_PID', raising=False) + monkeypatch.setattr(progressbar.env, 'JUPYTER', False) assert not progressbar.env.is_ansi_terminal(fd) assert progressbar.env.is_ansi_terminal(fd, True) is True assert progressbar.env.is_ansi_terminal(fd, False) is False - monkeypatch.setenv('JPY_PARENT_PID', '123') + monkeypatch.setattr(progressbar.env, 'JUPYTER', True) assert progressbar.env.is_ansi_terminal(fd) is True - monkeypatch.delenv('JPY_PARENT_PID') + monkeypatch.setattr(progressbar.env, 'JUPYTER', False) # Sanity check assert not progressbar.env.is_ansi_terminal(fd) diff --git a/tests/test_windows.py b/tests/test_windows.py index 51bed5c..be2e2a9 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -1,16 +1,17 @@ +import os import sys import time import pytest -if sys.platform.startswith('win'): +if os.name == 'nt': import win32console # "pip install pypiwin32" to get this else: pytest.skip('skipping windows-only tests', allow_module_level=True) - import progressbar +pytest_plugins = 'pytester' _WIDGETS = [progressbar.Percentage(), ' ', progressbar.Bar(), ' ', progressbar.FileTransferSpeed(), ' ', @@ -58,7 +59,12 @@ def find(lines, x): # --------------------------------------------------------------------------- -def test_windows(): +def test_windows(testdir: pytest.Testdir) -> None: + testdir.run(sys.executable, '-c', + 'import progressbar; print(progressbar.__file__)') + + +def main(): runprogress() scraped_lines = scrape_console(100) @@ -72,3 +78,7 @@ def test_windows(): print(f'{index_begin=} {index_end=}') return 1 return 0 + + +if __name__ == '__main__': + main() From 76fb9b1da33bbbdedb7e7021eebb6419062c74ef Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 25 Feb 2024 08:20:23 +0100 Subject: [PATCH 13/17] Added docs for env module --- docs/progressbar.env.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/progressbar.env.rst diff --git a/docs/progressbar.env.rst b/docs/progressbar.env.rst new file mode 100644 index 0000000..a818e0b --- /dev/null +++ b/docs/progressbar.env.rst @@ -0,0 +1,7 @@ +progressbar.env module +====================== + +.. automodule:: progressbar.env + :members: + :undoc-members: + :show-inheritance: From 3b2fe80afeb0fe6b13f163ec7c6c9e81d4ab5a73 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 25 Feb 2024 08:32:34 +0100 Subject: [PATCH 14/17] Fixed docs build and pyright issue --- progressbar/bar.py | 111 ++++++++++--------- progressbar/env.py | 18 +-- progressbar/terminal/os_specific/__init__.py | 4 +- pyproject.toml | 2 +- 4 files changed, 70 insertions(+), 65 deletions(-) diff --git a/progressbar/bar.py b/progressbar/bar.py index 8b859b2..ca46fd3 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -187,13 +187,13 @@ class DefaultFdMixin(ProgressBarMixinBase): enable_colors: progressbar.env.ColorSupport = progressbar.env.COLOR_SUPPORT def __init__( - self, - fd: base.TextIO = sys.stderr, - is_terminal: bool | None = None, - line_breaks: bool | None = None, - enable_colors: progressbar.env.ColorSupport | None = None, - line_offset: int = 0, - **kwargs, + self, + fd: base.TextIO = sys.stderr, + is_terminal: bool | None = None, + line_breaks: bool | None = None, + enable_colors: progressbar.env.ColorSupport | None = None, + line_offset: int = 0, + **kwargs, ): if fd is sys.stdout: fd = utils.streams.original_stdout @@ -210,9 +210,9 @@ def __init__( super().__init__(**kwargs) def _apply_line_offset( - self, - fd: base.TextIO, - line_offset: int, + self, + fd: base.TextIO, + line_offset: int, ) -> base.TextIO: if line_offset: return progressbar.terminal.stream.LineOffsetStreamWrapper( @@ -232,8 +232,8 @@ def _determine_line_breaks(self, line_breaks: bool | None) -> bool | None: return line_breaks def _determine_enable_colors( - self, - enable_colors: progressbar.env.ColorSupport | None, + self, + enable_colors: progressbar.env.ColorSupport | None, ) -> progressbar.env.ColorSupport: ''' Determines the color support for the progress bar. @@ -309,9 +309,9 @@ def update(self, *args: types.Any, **kwargs: types.Any) -> None: self.fd.write(types.cast(str, line.encode('ascii', 'replace'))) def finish( - self, - *args: types.Any, - **kwargs: types.Any, + self, + *args: types.Any, + **kwargs: types.Any, ) -> None: # pragma: no cover if self._finished: return @@ -341,8 +341,8 @@ def _format_widgets(self): for index, widget in enumerate(self.widgets): if isinstance( - widget, - widgets.WidgetBase, + widget, + widgets.WidgetBase, ) and not widget.check_size(self): continue elif isinstance(widget, widgets.AutoWidthWidgetBase): @@ -388,9 +388,11 @@ def __init__(self, term_width: int | None = None, **kwargs): import signal self._prev_handle = signal.getsignal( - signal.SIGWINCH) # type: ignore - signal.signal(signal.SIGWINCH, # type: ignore - self._handle_resize) + signal.SIGWINCH # type: ignore + ) + signal.signal( + signal.SIGWINCH, self._handle_resize # type: ignore + ) self.signal_set = True def _handle_resize(self, signum=None, frame=None): @@ -404,8 +406,9 @@ def finish(self): # pragma: no cover with contextlib.suppress(Exception): import signal - signal.signal(signal.SIGWINCH, # type: ignore - self._prev_handle) + signal.signal( + signal.SIGWINCH, self._prev_handle # type: ignore + ) class StdRedirectMixin(DefaultFdMixin): @@ -417,10 +420,10 @@ class StdRedirectMixin(DefaultFdMixin): _stderr: base.IO def __init__( - self, - redirect_stderr: bool = False, - redirect_stdout: bool = False, - **kwargs, + self, + redirect_stderr: bool = False, + redirect_stdout: bool = False, + **kwargs, ): DefaultFdMixin.__init__(self, **kwargs) self.redirect_stderr = redirect_stderr @@ -548,23 +551,23 @@ class ProgressBar( paused: bool = False def __init__( - self, - min_value: NumberT = 0, - max_value: NumberT | types.Type[base.UnknownLength] | None = None, - widgets: types.Optional[ - types.Sequence[widgets_module.WidgetBase | str] - ] = None, - left_justify: bool = True, - initial_value: NumberT = 0, - poll_interval: types.Optional[float] = None, - widget_kwargs: types.Optional[types.Dict[str, types.Any]] = None, - custom_len: types.Callable[[str], int] = utils.len_color, - max_error=True, - prefix=None, - suffix=None, - variables=None, - min_poll_interval=None, - **kwargs, + self, + min_value: NumberT = 0, + max_value: NumberT | types.Type[base.UnknownLength] | None = None, + widgets: types.Optional[ + types.Sequence[widgets_module.WidgetBase | str] + ] = None, + left_justify: bool = True, + initial_value: NumberT = 0, + poll_interval: types.Optional[float] = None, + widget_kwargs: types.Optional[types.Dict[str, types.Any]] = None, + custom_len: types.Callable[[str], int] = utils.len_color, + max_error=True, + prefix=None, + suffix=None, + variables=None, + min_poll_interval=None, + **kwargs, ): # sourcery skip: low-code-quality '''Initializes a progress bar with sane defaults.''' StdRedirectMixin.__init__(self, **kwargs) @@ -629,8 +632,8 @@ def __init__( default=None, ) self._MINIMUM_UPDATE_INTERVAL = ( - utils.deltas_to_seconds(self._MINIMUM_UPDATE_INTERVAL) - or self._MINIMUM_UPDATE_INTERVAL + utils.deltas_to_seconds(self._MINIMUM_UPDATE_INTERVAL) + or self._MINIMUM_UPDATE_INTERVAL ) # Note that the _MINIMUM_UPDATE_INTERVAL sets the minimum in case of @@ -646,8 +649,8 @@ def __init__( self.variables = utils.AttributeDict(variables or {}) for widget in self.widgets: if ( - isinstance(widget, widgets_module.VariableMixin) - and widget.name not in self.variables + isinstance(widget, widgets_module.VariableMixin) + and widget.name not in self.variables ): self.variables[widget.name] = None @@ -768,7 +771,7 @@ def data(self) -> types.Dict[str, types.Any]: total_seconds_elapsed=total_seconds_elapsed, # The seconds since the bar started modulo 60 seconds_elapsed=(elapsed.seconds % 60) - + (elapsed.microseconds / 1000000.0), + + (elapsed.microseconds / 1000000.0), # The minutes since the bar started modulo 60 minutes_elapsed=(elapsed.seconds / 60) % 60, # The hours since the bar started modulo 24 @@ -898,9 +901,9 @@ def update(self, value=None, force=False, **kwargs): self.start() if ( - value is not None - and value is not base.UnknownLength - and isinstance(value, (int, float)) + value is not None + and value is not base.UnknownLength + and isinstance(value, (int, float)) ): if self.max_value is base.UnknownLength: # Can't compare against unknown lengths so just update @@ -1023,9 +1026,9 @@ def _init_prefix(self): def _verify_max_value(self): if ( - self.max_value is not base.UnknownLength - and self.max_value is not None - and self.max_value < 0 # type: ignore + self.max_value is not base.UnknownLength + and self.max_value is not None + and self.max_value < 0 # type: ignore ): raise ValueError('max_value out of range, got %r' % self.max_value) diff --git a/progressbar/env.py b/progressbar/env.py index d7990d2..e29f6fb 100644 --- a/progressbar/env.py +++ b/progressbar/env.py @@ -74,8 +74,8 @@ def from_env(cls): from .terminal.os_specific import windows if ( - windows.get_console_mode() - & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + windows.get_console_mode() + & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT ): return cls.XTERM_TRUECOLOR else: @@ -99,8 +99,8 @@ def from_env(cls): def is_ansi_terminal( - fd: base.IO, - is_terminal: bool | None = None, + fd: base.IO, + is_terminal: bool | None = None, ) -> bool | None: # pragma: no cover if is_terminal is None: # Jupyter Notebooks support progress bars @@ -109,7 +109,8 @@ def is_ansi_terminal( # This works for newer versions of pycharm only. With older versions # there is no way to check. elif os.environ.get('PYCHARM_HOSTED') == '1' and not os.environ.get( - 'PYTEST_CURRENT_TEST'): + 'PYTEST_CURRENT_TEST' + ): is_terminal = True if is_terminal is None: @@ -165,8 +166,11 @@ def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool | None: os_specific.set_console_mode() -JUPYTER = bool(os.environ.get('JUPYTER_COLUMNS') or os.environ.get( - 'JUPYTER_LINES') or os.environ.get('JPY_PARENT_PID')) +JUPYTER = bool( + os.environ.get('JUPYTER_COLUMNS') + or os.environ.get('JUPYTER_LINES') + or os.environ.get('JPY_PARENT_PID') +) COLOR_SUPPORT = ColorSupport.from_env() ANSI_TERMS = ( '([xe]|bv)term', diff --git a/progressbar/terminal/os_specific/__init__.py b/progressbar/terminal/os_specific/__init__.py index 4fae3c3..833feeb 100644 --- a/progressbar/terminal/os_specific/__init__.py +++ b/progressbar/terminal/os_specific/__init__.py @@ -11,18 +11,16 @@ else: from .posix import getch as _getch - def _reset_console_mode() -> None: pass - def _set_console_mode() -> bool: return False - def _get_console_mode() -> int: return 0 + getch = _getch reset_console_mode = _reset_console_mode set_console_mode = _set_console_mode diff --git a/pyproject.toml b/pyproject.toml index 904e717..6770f0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,7 +112,7 @@ include-package-data = true progressbar = 'progressbar.cli:main' [project.optional-dependencies] -docs = ['sphinx>=1.8.5', 'sphinx-autodoc-typehints>=1.6.0'] +docs = ['sphinx>=1.8.5', 'sphinx-autodoc-typehints>=1.6.0', 'termios'] tests = [ 'dill>=0.3.6', 'flake8>=3.7.7', From d6e4c1fc65b8d3dac4fe1665ac30201d06abe172 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 25 Feb 2024 08:36:54 +0100 Subject: [PATCH 15/17] docs build fix --- docs/progressbar.terminal.os_specific.posix.rst | 7 ------- docs/progressbar.terminal.os_specific.rst | 3 --- docs/progressbar.terminal.os_specific.windows.rst | 7 ------- pyproject.toml | 2 +- 4 files changed, 1 insertion(+), 18 deletions(-) delete mode 100644 docs/progressbar.terminal.os_specific.posix.rst delete mode 100644 docs/progressbar.terminal.os_specific.windows.rst diff --git a/docs/progressbar.terminal.os_specific.posix.rst b/docs/progressbar.terminal.os_specific.posix.rst deleted file mode 100644 index 7d1ec49..0000000 --- a/docs/progressbar.terminal.os_specific.posix.rst +++ /dev/null @@ -1,7 +0,0 @@ -progressbar.terminal.os\_specific.posix module -============================================== - -.. automodule:: progressbar.terminal.os_specific.posix - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/progressbar.terminal.os_specific.rst b/docs/progressbar.terminal.os_specific.rst index 456ef9c..b00648e 100644 --- a/docs/progressbar.terminal.os_specific.rst +++ b/docs/progressbar.terminal.os_specific.rst @@ -7,9 +7,6 @@ Submodules .. toctree:: :maxdepth: 4 - progressbar.terminal.os_specific.posix - progressbar.terminal.os_specific.windows - Module contents --------------- diff --git a/docs/progressbar.terminal.os_specific.windows.rst b/docs/progressbar.terminal.os_specific.windows.rst deleted file mode 100644 index 0595e93..0000000 --- a/docs/progressbar.terminal.os_specific.windows.rst +++ /dev/null @@ -1,7 +0,0 @@ -progressbar.terminal.os\_specific.windows module -================================================ - -.. automodule:: progressbar.terminal.os_specific.windows - :members: - :undoc-members: - :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index 6770f0d..904e717 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,7 +112,7 @@ include-package-data = true progressbar = 'progressbar.cli:main' [project.optional-dependencies] -docs = ['sphinx>=1.8.5', 'sphinx-autodoc-typehints>=1.6.0', 'termios'] +docs = ['sphinx>=1.8.5', 'sphinx-autodoc-typehints>=1.6.0'] tests = [ 'dill>=0.3.6', 'flake8>=3.7.7', From 9fdfe74c09a104ed4960f439ea64d122eb45bb41 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 25 Feb 2024 09:52:09 +0100 Subject: [PATCH 16/17] 100% test coverage --- tests/test_color.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_color.py b/tests/test_color.py index 14b5899..dc7c2bb 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -55,6 +55,10 @@ def test_color_environment_variables(monkeypatch: pytest.MonkeyPatch, assert not bar.is_ansi_terminal assert bar.enable_colors + monkeypatch.setenv(variable, 'false') + bar = progressbar.ProgressBar() + assert not bar.enable_colors + monkeypatch.setenv(variable, '') bar = progressbar.ProgressBar() assert not bar.enable_colors From 0a3c1bd906fd782a6d3ba762d1ec7ec3216628a5 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 25 Feb 2024 09:53:51 +0100 Subject: [PATCH 17/17] Incrementing version to v4.4.0 --- progressbar/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/progressbar/__about__.py b/progressbar/__about__.py index 6279c36..dee8e02 100644 --- a/progressbar/__about__.py +++ b/progressbar/__about__.py @@ -21,7 +21,7 @@ '''.strip().split(), ) __email__ = 'wolph@wol.ph' -__version__ = '4.3.2' +__version__ = '4.4.0' __license__ = 'BSD' __copyright__ = 'Copyright 2015 Rick van Hattem (Wolph)' __url__ = 'https://github.com/WoLpH/python-progressbar'