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/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/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: 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/examples.py b/examples.py index 8b7247c..00e565e 100644 --- a/examples.py +++ b/examples.py @@ -1,7 +1,9 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- +from __future__ import annotations +import contextlib import functools +import os import random import sys import time @@ -9,7 +11,7 @@ import progressbar -examples: typing.List[typing.Callable[[typing.Any], typing.Any]] = [] +examples: list[typing.Callable[[typing.Any], typing.Any]] = [] def example(fn): @@ -40,19 +42,83 @@ 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 _ 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) + multibar[bar_label] + + for _ 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 + + 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 _ 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}'): + for _ in progressbar.progressbar(range(10), suffix='{seconds_elapsed:.1}'): time.sleep(0.1) @@ -62,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 @@ -141,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: @@ -149,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: @@ -167,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 = [ @@ -197,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) @@ -237,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() @@ -270,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() @@ -286,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() @@ -337,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) @@ -345,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) @@ -354,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) @@ -367,7 +435,7 @@ def filling_bar_animated_marker(): ), ] ) - for i in bar(range(15)): + for _ in bar(range(15)): time.sleep(0.1) @@ -381,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) @@ -391,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) @@ -399,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) @@ -409,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') @@ -421,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') @@ -433,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') @@ -446,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) @@ -458,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): @@ -474,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 @@ -504,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) @@ -529,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) @@ -545,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 @@ -581,13 +636,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(), @@ -617,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() @@ -628,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 @@ -698,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 ) @@ -736,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 = [ @@ -755,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 = [ @@ -775,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/__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' diff --git a/progressbar/__init__.py b/progressbar/__init__.py index 4382499..ff76ff4 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -1,6 +1,11 @@ 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 @@ -32,6 +37,7 @@ ReverseBar, RotatingMarker, SimpleProgress, + SmoothingETA, Timer, Variable, VariableMixin, @@ -46,6 +52,10 @@ 'ETA', 'AdaptiveETA', 'AbsoluteETA', + 'SmoothingETA', + 'SmoothingAlgorithm', + 'ExponentialMovingAverage', + 'DoubleExponentialMovingAverage', 'DataSize', 'FileTransferSpeed', 'AdaptiveTransferSpeed', diff --git a/progressbar/__main__.py b/progressbar/__main__.py new file mode 100644 index 0000000..431aa31 --- /dev/null +++ b/progressbar/__main__.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import argparse +import contextlib +import pathlib +import sys +import typing +from pathlib import Path +from typing import BinaryIO, TextIO + +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 = 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] | None = None): # noqa: C901 + ''' + Main function for the `progressbar` command. + + 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: + output_stream: typing.IO[typing.Any] = _get_output_stream( + args.output, args.line_mode, stack + ) + + input_paths: list[BinaryIO | TextIO | Path] = [] + total_size: int = 0 + filesize_available: bool = True + for filename in args.input: + input_path: typing.IO[typing.Any] | 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' 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: + 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) + + +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/algorithms.py b/progressbar/algorithms.py new file mode 100644 index 0000000..bb8586e --- /dev/null +++ b/progressbar/algorithms.py @@ -0,0 +1,51 @@ +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/progressbar/bar.py b/progressbar/bar.py index d722100..ca46fd3 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__ @@ -162,13 +167,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 +184,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 +203,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 +222,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 if enable_colors is None: colors = ( progressbar.env.env_flag('PROGRESSBAR_ENABLE_COLORS'), @@ -252,23 +273,23 @@ def _determine_enable_colors( for color_enabled in colors: if color_enabled is not None: if color_enabled: - enable_colors = progressbar.env.COLOR_SUPPORT + color_support = progressbar.env.COLOR_SUPPORT else: - enable_colors = progressbar.env.ColorSupport.NONE + color_support = 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: + color_support = progressbar.env.ColorSupport.NONE elif enable_colors is True: - enable_colors = progressbar.env.ColorSupport.XTERM_256 + color_support = progressbar.env.ColorSupport.XTERM_256 elif enable_colors is False: - enable_colors = 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_colors + return color_support def print(self, *args: types.Any, **kwargs: types.Any) -> None: print(*args, file=self.fd, **kwargs) @@ -366,8 +387,12 @@ 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, self._handle_resize # type: ignore + ) self.signal_set = True def _handle_resize(self, signum=None, frame=None): @@ -381,7 +406,9 @@ def finish(self): # pragma: no cover with contextlib.suppress(Exception): import signal - signal.signal(signal.SIGWINCH, self._prev_handle) + signal.signal( + signal.SIGWINCH, self._prev_handle # type: ignore + ) class StdRedirectMixin(DefaultFdMixin): @@ -776,7 +803,7 @@ def default_widgets(self): ' ', widgets.Timer(**self.widget_kwargs), ' ', - widgets.AdaptiveETA(**self.widget_kwargs), + widgets.SmoothingETA(**self.widget_kwargs), ] else: return [ @@ -876,7 +903,7 @@ def update(self, value=None, force=False, **kwargs): if ( value is not None and value is not base.UnknownLength - and isinstance(value, int) + and isinstance(value, (int, float)) ): if self.max_value is base.UnknownLength: # Can't compare against unknown lengths so just update @@ -1071,7 +1098,7 @@ def default_widgets(self): ' ', widgets.Timer(), ' ', - widgets.AdaptiveETA(), + widgets.SmoothingETA(), ] else: return [ diff --git a/progressbar/env.py b/progressbar/env.py index 07e6666..e29f6fb 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 @@ -9,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): @@ -41,6 +40,7 @@ class ColorSupport(enum.IntEnum): XTERM = 16 XTERM_256 = 256 XTERM_TRUECOLOR = 16777216 + WINDOWS = 8 @classmethod def from_env(cls): @@ -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,11 +64,22 @@ 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': + # 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 # pragma: no cover support = cls.NONE for variable in variables: @@ -90,15 +101,15 @@ def from_env(cls): def is_ansi_terminal( fd: base.IO, is_terminal: bool | None = None, -) -> bool: # pragma: no cover +) -> 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 @@ -108,7 +119,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,15 +127,20 @@ 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: +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 @@ -141,9 +157,20 @@ 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 +if os.name == 'nt': + from .terminal import os_specific + 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/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 8c9b262..895887b 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -3,6 +3,7 @@ import abc import collections import colorsys +import enum import threading from collections import defaultdict @@ -178,6 +179,79 @@ 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 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)) + + return min( + WindowsColors, + key=lambda color: color_distance(color.value, rgb), + ) + + +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): + 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 +281,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,15 +368,24 @@ 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]: @@ -335,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 @@ -475,6 +566,14 @@ def apply_colors( return text +class DummyColor: + def __call__(self, text): + return text + + 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..833feeb 100644 --- a/progressbar/terminal/os_specific/__init__.py +++ b/progressbar/terminal/os_specific/__init__.py @@ -1,7 +1,8 @@ -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, reset_console_mode as _reset_console_mode, set_console_mode as _set_console_mode, @@ -10,13 +11,17 @@ 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() -> int: + 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/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/progressbar/terminal/os_specific/windows.py b/progressbar/terminal/os_specific/windows.py index f294845..425d349 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): @@ -101,21 +120,38 @@ 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)) -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 + | 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) -> None: + _kernel32.SetConsoleTextAttribute(_h_console_output, color) + + +def print_color(text, color): + set_text_color(color) + print(text) # noqa: T201 + set_text_color(7) # Reset to default color, grey def getch(): diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 40f2972..e5046b6 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -13,7 +13,7 @@ from python_utils import containers, converters, types -from . import base, terminal, utils +from . import algorithms, base, terminal, utils from .terminal import colors if types.TYPE_CHECKING: @@ -604,7 +604,17 @@ class AdaptiveETA(ETA, SamplesMixin): 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) @@ -628,6 +638,50 @@ 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. @@ -1202,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/pyproject.toml b/pyproject.toml index c4c2a95..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'] @@ -121,6 +121,7 @@ tests = [ 'pytest-mypy', 'pytest>=4.6.9', 'sphinx>=1.8.5', + 'pywin32; sys_platform == "win32"', ] [project.urls] @@ -180,6 +181,7 @@ exclude_lines = [ 'if __name__ == .__main__.:', 'if types.TYPE_CHECKING:', '@typing.overload', + 'if os.name == .nt.:', ] [tool.pyright] diff --git a/ruff.toml b/ruff.toml index 083e321..bd1b288 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,42 @@ select = [ 'UP', # pyupgrade ] -[per-file-ignores] +[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'] -[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_algorithms.py b/tests/test_algorithms.py new file mode 100644 index 0000000..85027ce --- /dev/null +++ b/tests/test_algorithms.py @@ -0,0 +1,47 @@ +from datetime import timedelta + +import pytest +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_color.py b/tests/test_color.py index 1a6657e..dc7c2bb 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -1,14 +1,33 @@ from __future__ import annotations +import os 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', @@ -17,18 +36,30 @@ '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, 'false') + bar = progressbar.ProgressBar() + assert not bar.enable_colors + + monkeypatch.setenv(variable, '') bar = progressbar.ProgressBar() assert not bar.enable_colors @@ -54,11 +85,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( @@ -69,8 +102,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(): @@ -81,7 +121,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 @@ -166,7 +206,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 @@ -174,18 +214,27 @@ def test_colors(): assert rgb.hex assert rgb.to_ansi_16 is not None assert rgb.to_ansi_256 is not None - assert color.underline + assert rgb.to_windows is not None + + 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(): 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) @@ -287,7 +336,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( @@ -302,6 +351,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_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_monitor_progress.py b/tests/test_monitor_progress.py index 7105254..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): @@ -140,20 +138,19 @@ 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_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_progressbar_command.py b/tests/test_progressbar_command.py new file mode 100644 index 0000000..05a3ab0 --- /dev/null +++ b/tests/test_progressbar_command.py @@ -0,0 +1,102 @@ +import io + +import progressbar.__main__ as main +import pytest + + +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 + + +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 + + +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')]) 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..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,27 +65,27 @@ 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 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 - 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 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 @@ -102,9 +102,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 - assert progressbar.env.is_ansi_terminal(fd) is False + assert not progressbar.env.is_ansi_terminal(fd) diff --git a/tests/test_windows.py b/tests/test_windows.py new file mode 100644 index 0000000..be2e2a9 --- /dev/null +++ b/tests/test_windows.py @@ -0,0 +1,84 @@ +import os +import sys +import time + +import pytest + +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(), ' ', + 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(lines, x): + try: + return lines.index(x) + except ValueError: + return -sys.maxsize + + +# --------------------------------------------------------------------------- +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) + # 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***') + + if index_end + 2 != index_begin: + print('ERROR: Unexpected multi-line output from progressbar') + print(f'{index_begin=} {index_end=}') + return 1 + return 0 + + +if __name__ == '__main__': + main()