From aab0a08d550b404e81e1e47a1081cfc6d09d0e0c Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Tue, 23 Jun 2026 15:15:04 +0200 Subject: [PATCH 1/2] Modernize README demos and progress features --- README.rst | 413 +++++------------ docs/_static/progressbar-hero.svg | 37 ++ docs/_static/progressbar-multibar.svg | 37 ++ docs/_static/progressbar-unknown-length.svg | 37 ++ docs/examples.rst | 4 +- docs/usage.rst | 33 +- progressbar/__init__.py | 4 + progressbar/__main__.py | 115 ++++- progressbar/bar.py | 160 +++++-- progressbar/multi.py | 40 +- progressbar/shortcuts.py | 10 + progressbar/utils.py | 80 +++- progressbar/widgets.py | 79 ++++ scripts/__init__.py | 1 + scripts/render_readme_demos.py | 482 ++++++++++++++++++++ tests/test_multibar.py | 94 ++++ tests/test_progressbar.py | 107 +++++ tests/test_progressbar_command.py | 82 ++++ tests/test_readme_demos.py | 345 ++++++++++++++ tests/test_stream.py | 278 ++++++++++- tests/test_widgets.py | 86 ++++ 21 files changed, 2136 insertions(+), 388 deletions(-) create mode 100644 docs/_static/progressbar-hero.svg create mode 100644 docs/_static/progressbar-multibar.svg create mode 100644 docs/_static/progressbar-unknown-length.svg create mode 100644 scripts/__init__.py create mode 100644 scripts/render_readme_demos.py create mode 100644 tests/test_readme_demos.py diff --git a/README.rst b/README.rst index 25a630c8..8301b6a1 100644 --- a/README.rst +++ b/README.rst @@ -1,354 +1,175 @@ ############################################################################## -Text progress bar library for Python. +progressbar2 ############################################################################## -Build status: +A mature, typed terminal progress bar library for Python scripts that need +custom widgets, clean output around prints and logs, multiple concurrent bars, +unknown-length progress, and pipe-friendly CLI usage. .. image:: https://github.com/WoLpH/python-progressbar/actions/workflows/main.yml/badge.svg - :alt: python-progressbar test status + :alt: python-progressbar test status :target: https://github.com/WoLpH/python-progressbar/actions -Coverage: - .. image:: https://coveralls.io/repos/WoLpH/python-progressbar/badge.svg?branch=master - :target: https://coveralls.io/r/WoLpH/python-progressbar?branch=master + :alt: coverage status + :target: https://coveralls.io/r/WoLpH/python-progressbar?branch=master -****************************************************************************** Install -****************************************************************************** +============================================================================== -The package can be installed through `pip` (this is the recommended method): +.. code:: sh pip install progressbar2 -Or if `pip` is not available, `easy_install` should work as well: - - easy_install progressbar2 - -Or download the latest release from Pypi (https://pypi.python.org/pypi/progressbar2) or Github. - -Note that the releases on Pypi are signed with my GPG key (https://pgp.mit.edu/pks/lookup?op=vindex&search=0xE81444E9CE1F695D) and can be checked using GPG: - - gpg --verify progressbar2-.tar.gz.asc progressbar2-.tar.gz - -****************************************************************************** -Introduction -****************************************************************************** - -A text progress bar is typically used to display the progress of a long -running operation, providing a visual cue that processing is underway. - -The progressbar is based on the old Python progressbar package that was published on the now defunct Google Code. Since that project was completely abandoned by its developer and the developer did not respond to email, I decided to fork the package. This package is still backwards compatible with the original progressbar package so you can safely use it as a drop-in replacement for existing project. - -The ProgressBar class manages the current progress, and the format of the line -is given by a number of widgets. A widget is an object that may display -differently depending on the state of the progress bar. There are many types -of widgets: - - - `AbsoluteETA `_ - - `AdaptiveETA `_ - - `AdaptiveTransferSpeed `_ - - `AnimatedMarker `_ - - `Bar `_ - - `BouncingBar `_ - - `Counter `_ - - `CurrentTime `_ - - `DataSize `_ - - `DynamicMessage `_ - - `ETA `_ - - `FileTransferSpeed `_ - - `FormatCustomText `_ - - `FormatLabel `_ - - `FormatLabelBar `_ - - `GranularBar `_ - - `Percentage `_ - - `PercentageLabelBar `_ - - `ReverseBar `_ - - `RotatingMarker `_ - - `SimpleProgress `_ - - `SmoothingETA `_ - - `Timer `_ - -The progressbar module is very easy to use, yet very powerful. It will also -automatically enable features like auto-resizing when the system supports it. - -****************************************************************************** -Known issues -****************************************************************************** - -- The Jetbrains (PyCharm, etc) editors work out of the box, but for more advanced features such as the `MultiBar` support you will need to enable the "Enable terminal in output console" checkbox in the Run dialog. -- The IDLE editor doesn't support these types of progress bars at all: https://bugs.python.org/issue23220 -- Jupyter notebooks buffer `sys.stdout` which can cause mixed output. This issue can be resolved easily using: `import sys; sys.stdout.flush()`. Linked issue: https://github.com/WoLpH/python-progressbar/issues/173 - -****************************************************************************** -Links -****************************************************************************** - -* Documentation - - https://progressbar-2.readthedocs.org/en/latest/ -* Source - - https://github.com/WoLpH/python-progressbar -* Bug reports - - https://github.com/WoLpH/python-progressbar/issues -* Package homepage - - https://pypi.python.org/pypi/progressbar2 -* My blog - - https://w.wol.ph/ - -****************************************************************************** -Usage -****************************************************************************** - -There are many ways to use Python Progressbar, you can see a few basic examples -here but there are many more in the examples file. - -Wrapping an iterable +Quick start ============================================================================== + .. code:: python import time import progressbar - for i in progressbar.progressbar(range(100)): + for item in progressbar.progressbar(range(100), desc='Loading'): time.sleep(0.02) -Progressbars with logging +Progress with clean logs ============================================================================== -Progressbars with logging require `stderr` redirection _before_ the -`StreamHandler` is initialized. To make sure the `stderr` stream has been -redirected on time make sure to call `progressbar.streams.wrap_stderr()` before -you initialize the `logger`. - -One option to force early initialization is by using the `WRAP_STDERR` -environment variable, on Linux/Unix systems this can be done through: - -.. code:: sh - - # WRAP_STDERR=true python your_script.py - -If you need to flush manually while wrapping, you can do so using: - -.. code:: python - - import progressbar - - progressbar.streams.flush() - -In most cases the following will work as well, as long as you initialize the -`StreamHandler` after the wrapping has taken place. +.. image:: docs/_static/progressbar-hero.svg + :alt: progressbar2 showing clean progress output with logs .. code:: python + import sys import time - import logging import progressbar - progressbar.streams.wrap_stderr() - logging.basicConfig() - - for i in progressbar.progressbar(range(10)): - logging.error('Got %d', i) - time.sleep(0.2) - -Multiple (threaded) progressbars + with progressbar.ProgressBar( + total=24, + desc='Build', + fd=sys.stdout, + redirect_stdout=True, + line_breaks=False, + is_terminal=True, + enable_colors=True, + term_width=112, + ) as bar: + for step in range(24): + if step in {8, 16}: + print(f'log: completed step {step}') + bar.update(step + 1, force=True) + time.sleep(0.005) + +Multiple bars ============================================================================== -.. code:: python +.. image:: docs/_static/progressbar-multibar.svg + :alt: multiple progress bars updating together - import random - import threading - import time +.. code:: python + import io + import re import progressbar - 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) - - # print messages at random intervals to show how extra output works - if random.random() > 0.9: - bar.print('random message for bar', bar, i) - - - with progressbar.MultiBar() as multibar: - for i in range(BARS): - # Get a progressbar - bar = multibar[f'Thread label here {i}'] - # Create a thread and pass the progressbar - threading.Thread(target=do_something, args=(bar,)).start() - -Context wrapper + fd = io.StringIO() + multibar = progressbar.MultiBar( + fd=fd, + total=24, + enable_colors=True, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + term_width=112, + ) + build = multibar['build'] + test = multibar['test'] + terminal_control_re = re.compile(r'\x1b\[[0-9;]*[A-Za-ln-z]') + + def emit_frame(): + output = terminal_control_re.sub('', fd.getvalue()) + for line in output.split('\r'): + line = line.strip() + if line: + print(line) + print('\f', end='') + fd.seek(0) + fd.truncate(0) + + multibar.render(force=True, flush=True) + emit_frame() + + for step in range(24): + build.update(step + 1, force=True) + test_value = min(24, max(0, round((step - 3) * 1.2))) + test.update(test_value, force=True) + multibar.render(force=True, flush=True) + emit_frame() + +Unknown length and animated bars ============================================================================== -.. code:: python - import time - import progressbar +.. image:: docs/_static/progressbar-unknown-length.svg + :alt: unknown length progress with an animated marker - with progressbar.ProgressBar(max_value=10) as bar: - for i in range(10): - time.sleep(0.1) - bar.update(i) - -Combining progressbars with print output -============================================================================== .. code:: python - import time + import sys import progressbar - for i in progressbar.progressbar(range(100), redirect_stdout=True): - print('Some text', i) - time.sleep(0.1) - -Progressbar with unknown length + with progressbar.ProgressBar( + max_value=progressbar.UnknownLength, + fd=sys.stdout, + line_breaks=False, + is_terminal=True, + enable_colors=True, + term_width=112, + ) as bar: + for value in range(0, 120, 10): + bar.update(value, force=True) + +CLI usage ============================================================================== -.. code:: python - import time - import progressbar +.. code:: sh - bar = progressbar.ProgressBar(max_value=progressbar.UnknownLength) - for i in range(20): - time.sleep(0.1) - bar.update(i) + progressbar --progress --timer --eta --rate --bytes input.bin -o output.bin -Bar with custom widgets +Feature highlights ============================================================================== -.. code:: python - import time - import progressbar +* Works as an iterable wrapper or a manually updated progress bar. +* Supports custom widgets, colors, granular bars, animated markers, and labels. +* Handles unknown-length iterators. +* Supports multiple concurrent progress bars with ``MultiBar``. +* Redirects stdout/stderr so regular output does not corrupt the active bar. +* Includes a pipe-friendly ``progressbar`` command. +* Ships typed package metadata. - widgets=[ - ' [', progressbar.Timer(), '] ', - progressbar.Bar(), - ' (', progressbar.ETA(), ') ', - ] - for i in progressbar.progressbar(range(20), widgets=widgets): - time.sleep(0.1) - -Bar with wide Chinese (or other multibyte) characters +Known terminal caveats ============================================================================== -.. code:: python - - # vim: fileencoding=utf-8 - import time - import progressbar - +* JetBrains IDEs need "Enable terminal in output console" for advanced + terminal behavior such as ``MultiBar``. +* IDLE does not support terminal progress bars. +* Jupyter buffers stdout; call ``sys.stdout.flush()`` when output appears late. - def custom_len(value): - # These characters take up more space - characters = { - '进': 2, - '度': 2, - } - - total = 0 - for c in value: - total += characters.get(c, 1) - - return total - - - bar = progressbar.ProgressBar( - widgets=[ - '进度: ', - progressbar.Bar(), - ' ', - progressbar.Counter(format='%(value)02d/%(max_value)d'), - ], - len_func=custom_len, - ) - for i in bar(range(10)): - time.sleep(0.1) - -Showing multiple independent progress bars in parallel +Project history ============================================================================== -.. code:: python - - import random - import sys - import time - - import progressbar - - 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) +progressbar2 is based on the old Python progressbar package that was published +on the now defunct Google Code. Since that project was completely abandoned by +its developer and the developer did not respond to email, I decided to fork the +package. - # The progress bar updates, normally you would do something useful here - for i in range(N * BARS): - time.sleep(0.005) +This package is still backwards compatible with the original progressbar package +so you can safely use it as a drop-in replacement for existing projects. - # Increment one of the progress bars at random - bars[random.randrange(0, BARS)].increment() - - # Print a status message to the `print_fd` below the progress bars - print(f'Hi, we are at update {i+1} of {N * BARS}', file=print_fd) - - # Cleanup the bars - for bar in bars: - bar.finish() - - # Add a newline to make sure the next print starts on a new line - print() - -****************************************************************************** - -Naturally we can do this from separate threads as well: - -.. code:: python - - import random - import threading - import time - - import progressbar - - BARS = 5 - N = 100 - - # Create the bars with the given line offset - bars = [] - for line_offset in range(BARS): - bars.append(progressbar.ProgressBar(line_offset=line_offset, max_value=N)) - - - class Worker(threading.Thread): - def __init__(self, bar): - super().__init__() - self.bar = bar - - def run(self): - for i in range(N): - time.sleep(random.random() / 25) - self.bar.update(i) - - - for bar in bars: - Worker(bar).start() +Links +============================================================================== - print() +* Documentation: https://progressbar-2.readthedocs.org/en/latest/ +* Source: https://github.com/WoLpH/python-progressbar +* Bug reports: https://github.com/WoLpH/python-progressbar/issues +* Package homepage: https://pypi.python.org/pypi/progressbar2 diff --git a/docs/_static/progressbar-hero.svg b/docs/_static/progressbar-hero.svg new file mode 100644 index 00000000..64e8e9fc --- /dev/null +++ b/docs/_static/progressbar-hero.svg @@ -0,0 +1,37 @@ + + + + + + + Progress with clean logs + Build: 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--Build: 4% (1 of 24) |## | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 8% (2 of 24) |#### | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 12% (3 of 24) |###### | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 16% (4 of 24) |######## | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 20% (5 of 24) |########## | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 29% (7 of 24) |############## | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 33% (8 of 24) |################# | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 37% (9 of 24) |################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 41% (10 of 24) |#################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 45% (11 of 24) |###################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 50% (12 of 24) |######################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 54% (13 of 24) |########################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 58% (14 of 24) |############################# | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 62% (15 of 24) |############################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 66% (16 of 24) |################################# | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 70% (17 of 24) |################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 75% (18 of 24) |##################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 83% (20 of 24) |######################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 87% (21 of 24) |########################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 91% (22 of 24) |############################################# | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 95% (23 of 24) |############################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 100% (24 of 24) |##################################################| Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 100% (24 of 24) |##################################################| Elapsed Time: 0:00:00 Time: 0:00:00 + diff --git a/docs/_static/progressbar-multibar.svg b/docs/_static/progressbar-multibar.svg new file mode 100644 index 00000000..f3e2dd5f --- /dev/null +++ b/docs/_static/progressbar-multibar.svg @@ -0,0 +1,37 @@ + + + + + + + Multiple active jobs + build 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 4% (1 of 24) |# | Elapsed Time: 0:00:00 ETA: 0:00:00test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 8% (2 of 24) |### | Elapsed Time: 0:00:00 ETA: 0:00:00test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 12% (3 of 24) |#### | Elapsed Time: 0:00:00 ETA: 0:00:00test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 16% (4 of 24) |###### | Elapsed Time: 0:00:00 ETA: 0:00:00test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 20% (5 of 24) |####### | Elapsed Time: 0:00:00 ETA: 0:00:00test 4% (1 of 24) |# | Elapsed Time: 0:00:00 ETA: 0:00:00build 25% (6 of 24) |######### | Elapsed Time: 0:00:00 ETA: 0:00:00test 8% (2 of 24) |### | Elapsed Time: 0:00:00 ETA: 0:00:00build 29% (7 of 24) |########## | Elapsed Time: 0:00:00 ETA: 0:00:00test 16% (4 of 24) |###### | Elapsed Time: 0:00:00 ETA: 0:00:00build 33% (8 of 24) |############ | Elapsed Time: 0:00:00 ETA: 0:00:00test 20% (5 of 24) |####### | Elapsed Time: 0:00:00 ETA: 0:00:00build 37% (9 of 24) |############# | Elapsed Time: 0:00:00 ETA: 0:00:00test 25% (6 of 24) |######### | Elapsed Time: 0:00:00 ETA: 0:00:00build 41% (10 of 24) |############### | Elapsed Time: 0:00:00 ETA: 0:00:00test 29% (7 of 24) |########## | Elapsed Time: 0:00:00 ETA: 0:00:00build 45% (11 of 24) |################ | Elapsed Time: 0:00:00 ETA: 0:00:00test 33% (8 of 24) |############ | Elapsed Time: 0:00:00 ETA: 0:00:00build 54% (13 of 24) |################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 45% (11 of 24) |################ | Elapsed Time: 0:00:00 ETA: 0:00:00build 58% (14 of 24) |##################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 50% (12 of 24) |################## | Elapsed Time: 0:00:00 ETA: 0:00:00build 62% (15 of 24) |###################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 54% (13 of 24) |################### | Elapsed Time: 0:00:00 ETA: 0:00:00build 66% (16 of 24) |######################## | Elapsed Time: 0:00:00 ETA: 0:00:00test 58% (14 of 24) |##################### | Elapsed Time: 0:00:00 ETA: 0:00:00build 70% (17 of 24) |######################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 66% (16 of 24) |######################## | Elapsed Time: 0:00:00 ETA: 0:00:00build 75% (18 of 24) |########################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 70% (17 of 24) |######################### | Elapsed Time: 0:00:00 ETA: 0:00:00build 79% (19 of 24) |############################ | Elapsed Time: 0:00:00 ETA: 0:00:00test 75% (18 of 24) |########################### | Elapsed Time: 0:00:00 ETA: 0:00:00build 83% (20 of 24) |############################## | Elapsed Time: 0:00:00 ETA: 0:00:00test 79% (19 of 24) |############################ | Elapsed Time: 0:00:00 ETA: 0:00:00build 87% (21 of 24) |############################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 83% (20 of 24) |############################## | Elapsed Time: 0:00:00 ETA: 0:00:00build 91% (22 of 24) |################################# | Elapsed Time: 0:00:00 ETA: 0:00:00test 91% (22 of 24) |################################# | Elapsed Time: 0:00:00 ETA: 0:00:00build 95% (23 of 24) |################################## | Elapsed Time: 0:00:00 ETA: 0:00:00test 95% (23 of 24) |################################## | Elapsed Time: 0:00:00 ETA: 0:00:00build 100% (24 of 24) |####################################| Elapsed Time: 0:00:00 ETA: 0:00:00test 100% (24 of 24) |####################################| Elapsed Time: 0:00:00 ETA: 0:00:00 + diff --git a/docs/_static/progressbar-unknown-length.svg b/docs/_static/progressbar-unknown-length.svg new file mode 100644 index 00000000..515970c7 --- /dev/null +++ b/docs/_static/progressbar-unknown-length.svg @@ -0,0 +1,37 @@ + + + + + + + Unknown length + / |# | 0 Elapsed Time: 0:00:00- |# | 0 Elapsed Time: 0:00:00\ |# | 10 Elapsed Time: 0:00:00| |# | 20 Elapsed Time: 0:00:00/ |# | 30 Elapsed Time: 0:00:00- |# | 40 Elapsed Time: 0:00:00\ |# | 50 Elapsed Time: 0:00:00| |# | 60 Elapsed Time: 0:00:00/ |# | 70 Elapsed Time: 0:00:00- |# | 80 Elapsed Time: 0:00:00\ |# | 90 Elapsed Time: 0:00:00| |# | 100 Elapsed Time: 0:00:00/ |# | 110 Elapsed Time: 0:00:00| |# | 110 Elapsed Time: 0:00:00 + diff --git a/docs/examples.rst b/docs/examples.rst index 769c4d47..ecb87b8c 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,5 +1,7 @@ Examples =================== -.. literalinclude:: ../examples.py +The :doc:`usage` guide and README show the generated overview demos. This page +keeps the full runnable example collection in sync with ``examples.py``. +.. literalinclude:: ../examples.py diff --git a/docs/usage.rst b/docs/usage.rst index 6aa03303..cdd530a1 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -16,6 +16,21 @@ Wrapping an iterable for i in bar(range(100)): time.sleep(0.02) +Tqdm-style options +------------------------------------------------------------------------------ +:: + + import progressbar + + for item in progressbar.progressbar( + range(10), + desc='Items', + total=10, + unit='it', + postfix={'state': 'running'}, + ): + pass + Context wrapper ------------------------------------------------------------------------------ :: @@ -37,10 +52,25 @@ Combining progressbars with print output bar = progressbar.ProgressBar(redirect_stdout=True) for i in range(100): - print 'Some text', i + print('Some text', i) time.sleep(0.1) bar.update(i) +Logging integration +------------------------------------------------------------------------------ +:: + + import logging + import progressbar + + progressbar.streams.wrap_stderr() + progressbar.streams.wrap_logging() + logging.basicConfig() + + with progressbar.ProgressBar(total=10, redirect_stderr=True) as bar: + logging.warning('message above the bar') + bar.update(1) + Progressbar with unknown length ------------------------------------------------------------------------------ :: @@ -67,4 +97,3 @@ Bar with custom widgets ]) for i in bar(range(20)): time.sleep(0.1) - diff --git a/progressbar/__init__.py b/progressbar/__init__.py index cf4de765..be07a633 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -34,11 +34,13 @@ MultiRangeBar, Percentage, PercentageLabelBar, + Postfix, ReverseBar, RotatingMarker, SimpleProgress, SmoothingETA, Timer, + UnitProgress, Variable, VariableMixin, ) @@ -72,6 +74,7 @@ 'NullBar', 'Percentage', 'PercentageLabelBar', + 'Postfix', 'ProgressBar', 'ReverseBar', 'RotatingMarker', @@ -80,6 +83,7 @@ 'SmoothingETA', 'SortKey', 'Timer', + 'UnitProgress', 'UnknownLength', 'Variable', 'VariableMixin', diff --git a/progressbar/__main__.py b/progressbar/__main__.py index 59e4117c..9523544a 100644 --- a/progressbar/__main__.py +++ b/progressbar/__main__.py @@ -4,6 +4,7 @@ import contextlib import pathlib import sys +import time import typing from pathlib import Path from typing import IO, BinaryIO, TextIO @@ -271,6 +272,81 @@ def create_argument_parser() -> argparse.ArgumentParser: return parser +def _default_widgets( + filesize_available: bool, +) -> list[progressbar.widgets.WidgetBase | str]: + if filesize_available: + return [ + progressbar.Percentage(), + ' ', + progressbar.Bar(), + ' ', + progressbar.Timer(), + ' ', + progressbar.FileTransferSpeed(), + ] + return [ + progressbar.SimpleProgress(), + ' ', + progressbar.DataSize(), + ' ', + progressbar.Timer(), + ] + + +def _append_widget_group( + widgets: list[progressbar.widgets.WidgetBase | str], + group: typing.Sequence[progressbar.widgets.WidgetBase | str], +) -> None: + if widgets: + widgets.append(' ') + widgets.extend(group) + + +def _build_widgets( + args: argparse.Namespace, + filesize_available: bool, +) -> list[progressbar.widgets.WidgetBase | str]: + if args.quiet: + return [] + + requested_widgets = [ + (args.progress, [progressbar.Percentage(), ' ', progressbar.Bar()]), + (args.bytes, [progressbar.DataSize()]), + (args.timer, [progressbar.Timer()]), + (args.eta, [progressbar.AdaptiveETA()]), + (args.fineta, [progressbar.AbsoluteETA()]), + (args.rate or args.average_rate, [progressbar.FileTransferSpeed()]), + ] + selected_widgets = [ + group for selected, group in requested_widgets if selected + ] + if not selected_widgets: + return _default_widgets(filesize_available) + + widgets: list[progressbar.widgets.WidgetBase | str] = [] + for group in selected_widgets: + _append_widget_group(widgets, group) + + return widgets + + +def _sleep_for_rate_limit( + rate_limit: int | None, + transferred: int, + started_at: float, + now: float | None = None, +) -> None: + if not rate_limit: + return + now = time.monotonic() if now is None else now + expected_elapsed = transferred / rate_limit + actual_elapsed = now - started_at + delay = expected_elapsed - actual_elapsed + if delay > 0: + time.sleep(delay) + + def main(argv: list[str] | None = None) -> None: # noqa: C901 """ Main function for the `progressbar` command. @@ -316,41 +392,27 @@ def main(argv: list[str] | None = None) -> None: # noqa: C901 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()) + widgets = _build_widgets(args, filesize_available) + progress_bar_class: type[progressbar.ProgressBar] = ( + progressbar.NullBar if args.quiet else progressbar.ProgressBar + ) # Initialize the progress bar - bar = progressbar.ProgressBar( + bar = progress_bar_class( widgets=widgets, max_value=total_size if filesize_available else None, max_error=False, + line_breaks=True if args.numeric else None, ) # Data processing and updating the progress bar buffer_size = ( size_to_bytes(args.buffer_size) if args.buffer_size else 1024 ) + rate_limit = ( + size_to_bytes(args.rate_limit) if args.rate_limit else None + ) + started_at = time.monotonic() total_transferred = 0 bar.start() @@ -395,6 +457,11 @@ def main(argv: list[str] | None = None) -> None: # noqa: C901 total_transferred += len(data) bar.update(total_transferred) + _sleep_for_rate_limit( + rate_limit, + total_transferred, + started_at, + ) bar.finish(dirty=True) diff --git a/progressbar/bar.py b/progressbar/bar.py index 03529017..1d480856 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -40,6 +40,49 @@ T = types.TypeVar('T') +def _resolve_max_value( + max_value: ValueT, + total: types.Optional[NumberT], + kwargs: dict[str, types.Any], +) -> ValueT: + if not max_value and kwargs.get('maxval') is not None: + warnings.warn( + 'The usage of `maxval` is deprecated, please use ' + '`max_value` instead', + DeprecationWarning, + stacklevel=2, + ) + max_value = types.cast(ValueT, kwargs.get('maxval')) + + if max_value is None and total is not None: + return total + return max_value + + +def _resolve_poll_interval( + poll_interval: types.Optional[float], + kwargs: dict[str, types.Any], +) -> types.Optional[float]: + if not poll_interval and kwargs.get('poll'): + warnings.warn( + 'The usage of `poll` is deprecated, please use ' + '`poll_interval` instead', + DeprecationWarning, + stacklevel=2, + ) + return types.cast(types.Optional[float], kwargs.get('poll')) + return poll_interval + + +def _resolve_prefix( + prefix: types.Optional[str], + desc: types.Optional[str], +) -> types.Optional[str]: + if prefix is None and desc is not None: + return f'{desc}: ' + return prefix + + class ProgressBarMixinBase(abc.ABC): _started = False _finished = False @@ -524,7 +567,8 @@ def start(self, *args: typing.Any, **kwargs: typing.Any): DefaultFdMixin.start(self, *args, **kwargs) def update(self, value: types.Optional[NumberT] = None): - cleared = not self.line_breaks and utils.streams.needs_clear() + needs_clear = utils.streams.needs_clear() + cleared = not self.line_breaks and needs_clear if cleared: self.fd.write('\r' + ' ' * self.term_width + '\r') @@ -535,13 +579,15 @@ def update(self, value: types.Optional[NumberT] = None): DefaultFdMixin.update(self, value=value) def finish(self, end='\n'): - DefaultFdMixin.finish(self, end=end) - utils.streams.stop_capturing(self) - if self.redirect_stdout: - utils.streams.unwrap_stdout() + try: + DefaultFdMixin.finish(self, end=end) + finally: + utils.streams.stop_capturing(self) + if self.redirect_stdout: + utils.streams.unwrap_stdout() - if self.redirect_stderr: - utils.streams.unwrap_stderr() + if self.redirect_stderr: + utils.streams.unwrap_stderr() class ProgressBar( @@ -645,29 +691,20 @@ def __init__( suffix=None, variables=None, min_poll_interval=None, + desc=None, + total=None, + unit='it', + unit_scale=False, + postfix=None, **kwargs, ): # sourcery skip: low-code-quality """Initializes a progress bar with sane defaults.""" StdRedirectMixin.__init__(self, **kwargs) ResizableMixin.__init__(self, **kwargs) ProgressBarBase.__init__(self, **kwargs) - if not max_value and kwargs.get('maxval') is not None: - warnings.warn( - 'The usage of `maxval` is deprecated, please use ' - '`max_value` instead', - DeprecationWarning, - stacklevel=1, - ) - max_value = kwargs.get('maxval') - - if not poll_interval and kwargs.get('poll'): - warnings.warn( - 'The usage of `poll` is deprecated, please use ' - '`poll_interval` instead', - DeprecationWarning, - stacklevel=1, - ) - poll_interval = kwargs.get('poll') + max_value = _resolve_max_value(max_value, total, kwargs) + prefix = _resolve_prefix(prefix, desc) + poll_interval = _resolve_poll_interval(poll_interval, kwargs) if max_value and min_value > types.cast(NumberT, max_value): raise ValueError( @@ -679,6 +716,15 @@ def __init__( self.max_value = max_value # type: ignore self.max_error = max_error + explicit_widgets = widgets is not None + self.unit = unit + self.unit_scale = unit_scale + self._auto_postfix = not explicit_widgets and postfix is not None + self._auto_postfix_added = False + normalized_variables = utils.AttributeDict(variables or {}) + if postfix is not None: + normalized_variables['postfix'] = postfix + # Only copy the widget if it's safe to copy. Most widgets are so we # assume this to be true self.widgets = [] @@ -724,7 +770,7 @@ def __init__( ) # type: ignore # A dictionary of names that can be used by Variable and FormatWidget - self.variables = utils.AttributeDict(variables or {}) + self.variables = normalized_variables for widget in self.widgets: if ( isinstance(widget, widgets_module.VariableMixin) @@ -862,6 +908,8 @@ def data(self) -> types.Dict[str, types.Any]: time_elapsed=elapsed, # Percentage as a float or `None` if no max_value is available percentage=self.percentage, + unit=self.unit, + unit_scale=self.unit_scale, # Dictionary of user-defined # :py:class:`progressbar.widgets.Variable`'s variables=self.variables, @@ -1089,24 +1137,37 @@ def start( if self.max_value is None: self.max_value = self._DEFAULT_MAXVAL - StdRedirectMixin.start(self, max_value=max_value) - ResizableMixin.start(self, max_value=max_value) - ProgressBarBase.start(self, max_value=max_value) - - # Constructing the default widgets is only done when we know max_value - if not self.widgets: - self.widgets = self.default_widgets() - - self._init_prefix() - self._init_suffix() - self._calculate_poll_interval() self._verify_max_value() - now = datetime.now() - self.start_time = self.initial_start_time or now - self.last_update_time = now - self._last_update_timer = timeit.default_timer() - self.update(self.min_value, force=True) + try: + StdRedirectMixin.start(self, max_value=max_value) + ResizableMixin.start(self, max_value=max_value) + ProgressBarBase.start(self, max_value=max_value) + + # Constructing the default widgets is only done when we know + # max_value + if not self.widgets: + self.widgets = self.default_widgets() + if self._auto_postfix and not self._auto_postfix_added: + self.widgets.append(widgets_module.Postfix()) + self._auto_postfix_added = True + + self._init_prefix() + self._init_suffix() + self._calculate_poll_interval() + + now = datetime.now() + self.start_time = self.initial_start_time or now + self.last_update_time = now + self._last_update_timer = timeit.default_timer() + self.update(self.min_value, force=True) + except Exception: + with contextlib.suppress(Exception): + StdRedirectMixin.finish(self, end='') + with contextlib.suppress(Exception): + ResizableMixin.finish(self) + ProgressBarBase.finish(self) + raise return self @@ -1168,13 +1229,16 @@ def finish(self, end: str = '\n', dirty: bool = False): # state, so extra calls are no-ops return - if not dirty: - self.end_time = datetime.now() - self.update(self.max_value, force=True) - - StdRedirectMixin.finish(self, end=end) - ResizableMixin.finish(self) - ProgressBarBase.finish(self) + try: + try: + if not dirty: + self.end_time = datetime.now() + self.update(self.max_value, force=True) + finally: + StdRedirectMixin.finish(self, end=end) + finally: + ResizableMixin.finish(self) + ProgressBarBase.finish(self) @property def currval(self): diff --git a/progressbar/multi.py b/progressbar/multi.py index fabd1b2d..ee64c04c 100644 --- a/progressbar/multi.py +++ b/progressbar/multi.py @@ -1,5 +1,6 @@ from __future__ import annotations +import collections.abc import enum import io import itertools @@ -81,7 +82,11 @@ class MultiBar(dict[str, bar.ProgressBar]): def __init__( self, - bars: typing.Iterable[tuple[str, bar.ProgressBar]] | None = None, + bars: ( + collections.abc.Mapping[str, bar.ProgressBar] + | typing.Iterable[tuple[str, bar.ProgressBar]] + | None + ) = None, fd: typing.TextIO = sys.stderr, prepend_label: bool = True, append_label: bool = False, @@ -130,17 +135,36 @@ def __init__( self._thread_finished = threading.Event() self._thread_closed = threading.Event() - super().__init__(bars or {}) + super().__init__() + + bar_items: typing.Iterable[tuple[str, bar.ProgressBar]] + if bars is None: + bar_items = () + elif isinstance(bars, collections.abc.Mapping): + bar_items = typing.cast( + typing.Iterable[tuple[str, bar.ProgressBar]], + bars.items(), + ) + else: + bar_items = bars + + for key, progress in bar_items: + self[key] = progress def __setitem__(self, key: str, bar: bar.ProgressBar): """Add a progressbar to the multibar.""" if bar.label != key or not key: # pragma: no branch bar.label = key + + if not ( + isinstance(bar.fd, stream.LastLineStream) + and bar.fd.stream is self.fd + ): bar.fd = stream.LastLineStream(self.fd) - bar.paused = True - # Essentially `bar.print = self.print`, but `mypy` doesn't - # like that - bar.print = self.print # type: ignore + + bar.paused = True + # 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 # constructor and forgot to call the super constructor @@ -251,7 +275,7 @@ def update( yield from self._render_finished_bar(bar_, now, expired, update) elif bar_.started(): - update() + yield update() else: if self.initial_format is None: bar_.start() @@ -284,7 +308,7 @@ def _render_finished_bar( if bar_.finished(): # pragma: no branch if self.finished_format is None: - update(force=False) + yield update(force=False) else: # pragma: no cover yield self.finished_format.format(label=bar_.label) diff --git a/progressbar/shortcuts.py b/progressbar/shortcuts.py index 220c8f23..a7706a74 100644 --- a/progressbar/shortcuts.py +++ b/progressbar/shortcuts.py @@ -17,6 +17,11 @@ def progressbar( widgets: typing.Sequence[widgets_module.WidgetBase | str] | None = None, prefix: str | None = None, suffix: str | None = None, + desc: str | None = None, + total: bar.ValueT = None, + unit: str = 'it', + unit_scale: bool = False, + postfix: typing.Any = None, **kwargs: typing.Any, ) -> typing.Generator[T, None, None]: progressbar_ = bar.ProgressBar( @@ -25,6 +30,11 @@ def progressbar( widgets=widgets, prefix=prefix, suffix=suffix, + desc=desc, + total=total, + unit=unit, + unit_scale=unit_scale, + postfix=postfix, **kwargs, ) yield from progressbar_(iterator) diff --git a/progressbar/utils.py b/progressbar/utils.py index 4a77da7a..47a07876 100644 --- a/progressbar/utils.py +++ b/progressbar/utils.py @@ -8,7 +8,7 @@ import os import re import sys -from collections.abc import Iterable, Iterator +from collections.abc import Iterable, Iterator, Mapping from types import TracebackType from python_utils import types @@ -242,7 +242,9 @@ class StreamWrapper: ] wrapped_stdout: int = 0 wrapped_stderr: int = 0 + wrapped_logging: int = 0 wrapped_excepthook: int = 0 + logging_handlers: list[tuple[logging.StreamHandler[base.IO], base.IO]] capturing: int = 0 listeners: set @@ -252,7 +254,9 @@ def __init__(self) -> None: self.original_excepthook = sys.excepthook self.wrapped_stdout = 0 self.wrapped_stderr = 0 + self.wrapped_logging = 0 self.wrapped_excepthook = 0 + self.logging_handlers = [] self.capturing = 0 self.listeners = set() @@ -318,6 +322,80 @@ def wrap_stderr(self) -> WrappingIO: return sys.stderr # type: ignore + def wrap_logging(self) -> None: + """Retarget stdout/stderr logging handlers to wrapped streams.""" + self.wrapped_logging += 1 + if self.wrapped_logging > 1: + return + + wrapped_streams = { + self.original_stdout: self.stdout, + self.original_stderr: self.stderr, + sys.stdout: self.stdout, + sys.stderr: self.stderr, + } + restore_streams: dict[object, base.IO] = {} + if isinstance(self.stdout, WrappingIO): + restore_streams[self.stdout] = self.original_stdout + if isinstance(self.stderr, WrappingIO): + restore_streams[self.stderr] = self.original_stderr + + seen: set[int] = set() + for logger_ in self._iter_loggers(): + for handler in logger_.handlers: + if id(handler) in seen: + continue + seen.add(id(handler)) + if not isinstance(handler, logging.StreamHandler): + continue + self._wrap_logging_handler( + handler, + wrapped_streams, + restore_streams, + ) + + def _wrap_logging_handler( + self, + handler: logging.StreamHandler[base.IO], + wrapped_streams: Mapping[types.Any, types.Any], + restore_streams: Mapping[types.Any, base.IO], + ) -> None: + stream = handler.stream + replacement = wrapped_streams.get(stream) + if replacement is not None and replacement is not stream: + if self._set_handler_stream(handler, replacement): + self.logging_handlers.append((handler, stream)) + elif (restore_stream := restore_streams.get(stream)) is not None: + self.logging_handlers.append((handler, restore_stream)) + + def unwrap_logging(self) -> None: + if self.wrapped_logging > 1: + self.wrapped_logging -= 1 + return + if not self.wrapped_logging: + return + + while self.logging_handlers: + handler, stream = self.logging_handlers.pop() + self._set_handler_stream(handler, stream) + self.wrapped_logging = 0 + + def _set_handler_stream( + self, + handler: logging.StreamHandler[base.IO], + stream: types.Any, + ) -> bool: + with contextlib.suppress(AttributeError, ValueError): + handler.setStream(stream) + return True + return False + + def _iter_loggers(self) -> types.Iterator[logging.Logger]: + yield logging.getLogger() + for logger_ in tuple(logging.Logger.manager.loggerDict.values()): + if isinstance(logger_, logging.Logger): + yield logger_ + def unwrap_excepthook(self) -> None: if self.wrapped_excepthook: self.wrapped_excepthook -= 1 diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 387b02eb..9aaa229e 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -948,6 +948,55 @@ def get_format( return self._apply_colors(output, data) +UNIT_PREFIXES = ('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi') +DEFAULT_UNIT = object() + + +def format_unit_value(value, unit='it', unit_scale=False) -> str: + if value in (None, base.UnknownLength): + return 'N/A' + if unit_scale: + scaled, power = utils.scale_1024(float(value), len(UNIT_PREFIXES)) + prefix = UNIT_PREFIXES[int(power)] + return f'{scaled:.1f} {prefix}{unit}' + if isinstance(value, float): + return f'{value:g} {unit}' + return f'{value} {unit}' + + +class UnitProgress(WidgetBase): + """Displays progress as a count with an optional unit and 1024 scaling.""" + + def __init__( + self, + unit=DEFAULT_UNIT, + unit_scale=DEFAULT_UNIT, + **kwargs: typing.Any, + ): + self.use_progress_unit = unit is DEFAULT_UNIT + self.use_progress_unit_scale = unit_scale is DEFAULT_UNIT + self.unit: str = ( + 'it' if unit is DEFAULT_UNIT else typing.cast(str, unit) + ) + self.unit_scale: bool = ( + False + if unit_scale is DEFAULT_UNIT + else typing.cast(bool, unit_scale) + ) + WidgetBase.__init__(self, **kwargs) + + def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: + unit = typing.cast(str, data.get('unit', self.unit)) + unit_scale = typing.cast(bool, data.get('unit_scale', self.unit_scale)) + if not self.use_progress_unit: + unit = self.unit + if not self.use_progress_unit_scale: + unit_scale = self.unit_scale + value = format_unit_value(data.get('value'), unit, unit_scale) + max_value = format_unit_value(data.get('max_value'), unit, unit_scale) + return f'{value} of {max_value}' + + class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase): """Returns progress as a count of the total (e.g.: "5 of 47").""" @@ -1200,6 +1249,36 @@ def __init__(self, name, **kwargs: typing.Any): self.name = name +class Postfix(VariableMixin, WidgetBase): + """Displays a live postfix string or key-value mapping.""" + + def __init__( + self, + name='postfix', + prefix=' ', + separator=', ', + **kwargs: typing.Any, + ): + self.prefix = prefix + self.separator = separator + VariableMixin.__init__(self, name=name) + WidgetBase.__init__(self, **kwargs) + + def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: + value = data['variables'].get(self.name) + if not value: + return '' + if isinstance(value, str): + rendered = value + elif isinstance(value, dict): + rendered = self.separator.join( + f'{key}={value[key]}' for key in sorted(value) + ) + else: + rendered = str(value) + return f'{self.prefix}{rendered}' + + class MultiRangeBar(Bar, VariableMixin): """ A bar with multiple sub-ranges, each represented by a different symbol. diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..69377900 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Helper scripts for maintainers.""" diff --git a/scripts/render_readme_demos.py b/scripts/render_readme_demos.py new file mode 100644 index 00000000..f614350a --- /dev/null +++ b/scripts/render_readme_demos.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +import argparse +import html +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +STATIC_DIR = ROOT / 'docs' / '_static' +TIMING_FIELD_RE = re.compile( + r'\b(Elapsed Time|ETA|Time):(\s+)\d+:\d{2}:\d{2}', +) +BAR_RE = re.compile(r'\|(?P(?:#+[\s#]*|))\|') +PERCENT_RE = re.compile(r'\b\d{1,3}%') +POSTFIX_RE = re.compile(r'\b[A-Za-z_][\w-]*=[^\s,]+') +LABEL_RE = re.compile(r'[A-Za-z][\w-]*:?') +ANSI_SGR_RE = re.compile(r'\x1b\[([0-9;]*)m') +ANIMATION_FRAME_SECONDS = 0.08 +MAX_ANIMATION_FRAMES = 24 +SVG_WIDTH = 1080 + + +@dataclass(frozen=True) +class Demo: + name: str + title: str + snippet: str + log_lines: int = 0 + + +DEMOS = [ + Demo( + 'hero', + 'Progress with clean logs', + """ +import sys +import time +import progressbar + +with progressbar.ProgressBar( + total=24, + desc='Build', + fd=sys.stdout, + redirect_stdout=True, + line_breaks=False, + is_terminal=True, + enable_colors=True, + term_width=112, +) as bar: + for step in range(24): + if step in {8, 16}: + print(f'log: completed step {step}') + bar.update(step + 1, force=True) + time.sleep(0.005) +""", + log_lines=2, + ), + Demo( + 'multibar', + 'Multiple active jobs', + """ +import io +import re +import progressbar + +fd = io.StringIO() +multibar = progressbar.MultiBar( + fd=fd, + total=24, + enable_colors=True, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + term_width=112, +) +build = multibar['build'] +test = multibar['test'] +terminal_control_re = re.compile(r'\\x1b\\[[0-9;]*[A-Za-ln-z]') + +def emit_frame(): + output = terminal_control_re.sub('', fd.getvalue()) + for line in output.split('\\r'): + line = line.strip() + if line: + print(line) + print('\\f', end='') + fd.seek(0) + fd.truncate(0) + +multibar.render(force=True, flush=True) +emit_frame() + +for step in range(24): + build.update(step + 1, force=True) + test_value = min(24, max(0, round((step - 3) * 1.2))) + test.update(test_value, force=True) + multibar.render(force=True, flush=True) + emit_frame() +""", + ), + Demo( + 'unknown-length', + 'Unknown length', + """ +import sys +import progressbar + +with progressbar.ProgressBar( + max_value=progressbar.UnknownLength, + fd=sys.stdout, + line_breaks=False, + is_terminal=True, + enable_colors=True, + term_width=112, +) as bar: + for value in range(0, 120, 10): + bar.update(value, force=True) +""", + ), +] + + +def capture_demo(demo: Demo) -> list[list[str]]: + env = os.environ.copy() + env['COLORFGBG'] = '15;0' + env['COLORTERM'] = 'truecolor' + env['PYTHONPATH'] = str(ROOT) + env['PYTHONIOENCODING'] = 'utf-8' + try: + result = subprocess.run( + [sys.executable, '-c', demo.snippet], + cwd=ROOT, + env=env, + text=True, + encoding='utf-8', + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + timeout=5, + ) + except subprocess.TimeoutExpired as error: + raise SystemExit(f'timed out capturing demo: {demo.name}') from error + + frames = parse_frames(result.stdout) + if demo.log_lines: + frames = keep_recent_logs_with_progress(frames, demo.log_lines) + return limit_animation_frames(frames) or [['No output captured']] + + +def normalize_terminal_line(line: str) -> str: + return TIMING_FIELD_RE.sub( + lambda match: f'{match.group(1)}:{match.group(2)}0:00:00', + line, + ) + + +def frames_from_output(output: str) -> list[list[str]]: + return limit_animation_frames(parse_frames(output)) or [ + ['No output captured'] + ] + + +def parse_frames(output: str) -> list[list[str]]: + frames: list[list[str]] = [] + output = output.replace('\x1b[2K', '') + if '\f' in output: + for raw_frame in output.split('\f'): + lines = [ + normalize_terminal_line(line.strip()) + for line in raw_frame.splitlines() + if line.strip() + ] + if lines: + frames.append(lines) + return frames + + for raw_frame in output.splitlines(): + for part in raw_frame.split('\r'): + line = part.strip() + if line: + frames.append([normalize_terminal_line(line)]) + return frames + + +def keep_recent_logs_with_progress( + frames: list[list[str]], + log_lines: int, +) -> list[list[str]]: + logs: list[str] = [] + output: list[list[str]] = [] + + for frame in frames: + log_frame = [line for line in frame if line.startswith('log:')] + progress_frame = [ + line for line in frame if not line.startswith('log:') + ] + if log_frame: + logs.extend(log_frame) + logs = logs[-log_lines:] + if progress_frame: + output.append(logs + progress_frame) + + return output + + +def limit_animation_frames(frames: list[list[str]]) -> list[list[str]]: + if len(frames) <= MAX_ANIMATION_FRAMES: + return frames + + last_index = len(frames) - 1 + selected = [ + round(index * last_index / (MAX_ANIMATION_FRAMES - 1)) + for index in range(MAX_ANIMATION_FRAMES) + ] + return [frames[index] for index in selected] + + +def tspan( + text: str, + class_name: str | None = None, + style: str | None = None, +) -> str: + if not text: + return '' + escaped = html.escape(text) + if style is not None: + return f'{escaped}' + if class_name is None: + return escaped + return f'{escaped}' + + +def xterm_256_to_rgb(color: int) -> tuple[int, int, int]: + if color < 16: + palette = ( + (0, 0, 0), + (128, 0, 0), + (0, 128, 0), + (128, 128, 0), + (0, 0, 128), + (128, 0, 128), + (0, 128, 128), + (192, 192, 192), + (128, 128, 128), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), + ) + return palette[max(0, color)] + + if color < 232: + color -= 16 + levels = (0, 95, 135, 175, 215, 255) + return ( + levels[color // 36], + levels[(color // 6) % 6], + levels[color % 6], + ) + + shade = 8 + (color - 232) * 10 + return shade, shade, shade + + +def ansi_rgb_style(red: int, green: int, blue: int) -> str: + return f'fill: #{red:02x}{green:02x}{blue:02x}' + + +def ansi_sgr_style(parameters: str, current_style: str | None) -> str | None: + codes = [int(code) if code else 0 for code in parameters.split(';')] + index = 0 + while index < len(codes): + code = codes[index] + if code in {0, 39}: + current_style = None + elif code == 38 and index + 1 < len(codes): + mode = codes[index + 1] + if mode == 2 and index + 4 < len(codes): + current_style = ansi_rgb_style( + codes[index + 2], + codes[index + 3], + codes[index + 4], + ) + index += 4 + elif mode == 5 and index + 2 < len(codes): + current_style = ansi_rgb_style( + *xterm_256_to_rgb(codes[index + 2]), + ) + index += 2 + else: + index += 1 + index += 1 + + return current_style + + +def styled_ansi_terminal_line(line: str) -> str: + output: list[str] = [] + cursor = 0 + current_style: str | None = None + for match in ANSI_SGR_RE.finditer(line): + output.append(tspan(line[cursor : match.start()], style=current_style)) + current_style = ansi_sgr_style(match.group(1), current_style) + cursor = match.end() + + output.append(tspan(line[cursor:], style=current_style)) + return ''.join(output) + + +def styled_text_segment( + text: str, + absolute_start: int, + full_line: str, +) -> str: + ranges: list[tuple[int, int, str]] = [] + if absolute_start == 0 and text.startswith('log:'): + ranges.append((0, 4, 'terminal-log')) + elif ( + absolute_start == 0 + and '%' in full_line + and (label_match := LABEL_RE.match(text)) + ): + ranges.append( + (label_match.start(), label_match.end(), 'terminal-label') + ) + + ranges.extend( + (match.start(), match.end(), 'terminal-percent') + for match in PERCENT_RE.finditer(text) + ) + ranges.extend( + (match.start(), match.end(), 'terminal-postfix') + for match in POSTFIX_RE.finditer(text) + ) + + output: list[str] = [] + cursor = 0 + for start, end, class_name in sorted(ranges): + if start < cursor: + continue + output.append(tspan(text[cursor:start])) + output.append(tspan(text[start:end], class_name)) + cursor = end + output.append(tspan(text[cursor:])) + return ''.join(output) + + +def styled_bar_segment(inner: str) -> str: + output = [tspan('|', 'terminal-bar-frame')] + for match in re.finditer(r'#+|\s+|[^#\s]+', inner): + value = match.group(0) + if set(value) == {'#'}: + class_name = 'terminal-bar-fill' + elif value.isspace(): + class_name = 'terminal-bar-empty' + else: + class_name = 'terminal-bar-text' + output.append(tspan(value, class_name)) + output.append(tspan('|', 'terminal-bar-frame')) + return ''.join(output) + + +def styled_terminal_line(line: str) -> str: + if '\x1b[' in line: + return styled_ansi_terminal_line(line) + + output: list[str] = [] + cursor = 0 + for match in BAR_RE.finditer(line): + output.append( + styled_text_segment(line[cursor : match.start()], cursor, line) + ) + output.append(styled_bar_segment(match.group('inner'))) + cursor = match.end() + output.append(styled_text_segment(line[cursor:], cursor, line)) + return ''.join(output) + + +def svg_document(title: str, frames: list[list[str]]) -> str: + width = SVG_WIDTH + line_height = 24 + max_lines = max(len(frame) for frame in frames) + height = 72 + max_lines * line_height + duration = f'{max(len(frames), 1) * ANIMATION_FRAME_SECONDS:g}' + frame_groups = [] + for index, frame in enumerate(frames): + visible_values = ['0'] * len(frames) + visible_values[index] = '1' + visible_value_list = ';'.join(visible_values) + base_opacity = '1' if index == 0 else '0' + lines = [] + for row, line in enumerate(frame): + lines.append( + f'' + f'{styled_terminal_line(line)}' + ) + frame_groups.append( + f'' + '' + ''.join(lines) + '' + ) + + return f''' + + + + + + {html.escape(title)} + {''.join(frame_groups)} + +''' + + +def render_svg(path: Path, title: str, frames: list[list[str]]) -> None: + svg = svg_document(title, frames) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(svg, encoding='utf-8') + + +def check_svg(path: Path, expected: str) -> None: + if not path.exists(): + raise SystemExit(f'missing generated asset: {path}') + if path.read_text(encoding='utf-8') != expected: + raise SystemExit(f'outdated generated asset: {path}') + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + for demo in DEMOS: + output = STATIC_DIR / f'progressbar-{demo.name}.svg' + frames = capture_demo(demo) + if args.check: + check_svg(output, svg_document(demo.title, frames)) + else: + render_svg(output, demo.title, frames) + + +if __name__ == '__main__': + main() diff --git a/tests/test_multibar.py b/tests/test_multibar.py index daf55a17..204a80d1 100644 --- a/tests/test_multibar.py +++ b/tests/test_multibar.py @@ -221,6 +221,100 @@ def test_multibar_finished() -> None: multibar.render(force=True) +def test_multibar_render_writes_started_bar_text() -> None: + fd = io.StringIO() + multibar = progressbar.MultiBar( + fd=fd, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + total=3, + term_width=64, + ) + build = multibar['build'] + test = multibar['test'] + multibar.render(force=True, flush=True) + fd.seek(0) + fd.truncate(0) + + build.update(1, force=True) + test.update(1, force=True) + multibar.render(force=True, flush=True) + + output = fd.getvalue() + assert 'build' in output + assert 'test' in output + assert '(1 of 3)' in output + + +def test_multibar_wraps_pre_labeled_bar_stream() -> None: + fd = io.StringIO() + multibar = progressbar.MultiBar( + fd=fd, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + total=3, + term_width=64, + ) + bar = progressbar.ProgressBar(max_value=3) + bar.label = 'build' + multibar['build'] = bar + + bar.update(1, force=True) + multibar.render(force=True, flush=True) + + output = fd.getvalue() + assert 'build' in output + assert '(1 of 3)' in output + + +def test_multibar_constructor_wraps_external_bar_stream() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar(max_value=3) + multibar = progressbar.MultiBar( + [('build', bar)], + fd=fd, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + total=3, + term_width=64, + ) + + bar.update(1, force=True) + multibar.render(force=True, flush=True) + + output = fd.getvalue() + assert 'build' in output + assert '(1 of 3)' in output + + +def test_multibar_constructor_accepts_mapping() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar(max_value=3) + multibar = progressbar.MultiBar( + {'build': bar}, + fd=fd, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + total=3, + term_width=64, + ) + + bar.update(1, force=True) + multibar.render(force=True, flush=True) + + output = fd.getvalue() + assert 'build' in output + assert '(1 of 3)' in output + + def test_multibar_finished_format() -> None: multibar = progressbar.MultiBar( finished_format='Finished {label}', show_finished=True diff --git a/tests/test_progressbar.py b/tests/test_progressbar.py index 2267b59d..207d78f7 100644 --- a/tests/test_progressbar.py +++ b/tests/test_progressbar.py @@ -85,6 +85,77 @@ def test_negative_maximum() -> None: progress.start() +def test_progressbar_accepts_total_alias() -> None: + bar = progressbar.ProgressBar(total=5, fd=io.StringIO()) + assert bar.max_value == 5 + + +def test_progressbar_max_value_wins_over_total() -> None: + bar = progressbar.ProgressBar(max_value=7, total=5, fd=io.StringIO()) + assert bar.max_value == 7 + + +def test_progressbar_desc_maps_to_prefix() -> None: + stream = io.StringIO() + with progressbar.ProgressBar( + desc='Loading', + max_value=1, + fd=stream, + ) as bar: + bar.update(1, force=True) + assert 'Loading' in stream.getvalue() + + +def test_progressbar_postfix_updates_live() -> None: + stream = io.StringIO() + widgets = [progressbar.Postfix()] + with progressbar.ProgressBar( + max_value=2, + widgets=widgets, + postfix={'loss': 1.0}, + fd=stream, + ) as bar: + bar.update(1, postfix={'loss': 0.5}, force=True) + assert 'loss=0.5' in stream.getvalue() + + +def test_progressbar_postfix_preserves_default_widgets() -> None: + stream = io.StringIO() + with progressbar.ProgressBar( + max_value=2, + postfix='ok', + fd=stream, + ) as bar: + bar.update(2, force=True) + rendered = stream.getvalue() + assert 'ok' in rendered + assert '100%' in rendered or '(2 of 2)' in rendered + + +def test_progressbar_empty_desc_maps_to_prefix() -> None: + stream = io.StringIO() + with progressbar.ProgressBar(desc='', max_value=1, fd=stream) as bar: + bar.update(1, force=True) + assert stream.getvalue().startswith(': ') + + +def test_shortcut_passes_total_desc_and_postfix() -> None: + stream = io.StringIO() + values = list( + progressbar.progressbar( + range(2), + total=2, + desc='Items', + postfix='ok', + fd=stream, + ) + ) + assert values == [0, 1] + rendered = stream.getvalue() + assert 'Items' in rendered + assert 'ok' in rendered + + def test_elapsed_data_spans_days() -> None: # Regression: A1 - days_elapsed was computed from timedelta.seconds, # which only contains the sub-day component. @@ -145,14 +216,50 @@ def write(self, value: str) -> int: unraisable: list[object] = [] monkeypatch.setattr(sys, 'unraisablehook', unraisable.append) + baseline_capturing = utils.streams.capturing bar = progressbar.ProgressBar(max_value=5, fd=io.StringIO(), term_width=60) bar.start() + bar_id = id(bar) + # The listener registry deliberately owns running bars. Remove this + # test's artificial reference so the finalizer path is exercised. + utils.streams.listeners.discard(bar) bar.fd = ExplodingIO() del bar gc.collect() assert not unraisable + assert all(id(listener) != bar_id for listener in utils.streams.listeners) + assert utils.streams.capturing == baseline_capturing + + +def test_finish_cleans_stream_listener_when_render_fails() -> None: + class ExplodingIO(io.StringIO): + def write(self, value: str) -> int: + raise ValueError('I/O operation on closed file') + + bar = progressbar.ProgressBar(max_value=5, fd=io.StringIO(), term_width=60) + bar.start() + assert bar in utils.streams.listeners + + bar.fd = ExplodingIO() + with pytest.raises(ValueError, match='I/O operation on closed file'): + bar.finish() + + assert bar not in utils.streams.listeners + + +def test_start_cleans_stream_listener_when_validation_fails() -> None: + bar = progressbar.ProgressBar( + min_value=-2, + max_value=-1, + fd=io.StringIO(), + ) + + with pytest.raises(ValueError, match='max_value out of range'): + bar.update(-1) + + assert bar not in utils.streams.listeners @pytest.mark.skipif(os.name == 'nt', reason='SIGWINCH is POSIX-only') diff --git a/tests/test_progressbar_command.py b/tests/test_progressbar_command.py index a277f8ca..771fca41 100644 --- a/tests/test_progressbar_command.py +++ b/tests/test_progressbar_command.py @@ -20,6 +20,39 @@ def test_size_to_bytes() -> None: assert main.size_to_bytes('1024p') == 1152921504606846976 +def test_sleep_for_rate_limit_skips_when_unset(monkeypatch) -> None: + calls = [] + monkeypatch.setattr(main.time, 'sleep', calls.append) + main._sleep_for_rate_limit(None, transferred=1024, started_at=0, now=1) + assert calls == [] + + +def test_sleep_for_rate_limit_sleeps_when_ahead(monkeypatch) -> None: + calls = [] + monkeypatch.setattr(main.time, 'sleep', calls.append) + main._sleep_for_rate_limit(1024, transferred=2048, started_at=0, now=1) + assert calls == [1] + + +def test_sleep_for_rate_limit_skips_when_on_schedule(monkeypatch) -> None: + calls = [] + monkeypatch.setattr(main.time, 'sleep', calls.append) + main._sleep_for_rate_limit(1024, transferred=1024, started_at=0, now=1) + assert calls == [] + + +def test_main_passes_rate_limit(tmp_path, monkeypatch) -> None: + sleeps = [] + monkeypatch.setattr(main.time, 'sleep', sleeps.append) + monkeypatch.setattr(main.time, 'monotonic', lambda: 0) + file = tmp_path / 'data.bin' + file.write_bytes(b'x' * 2048) + main.main( + ['--rate-limit', '1k', str(file), '-o', str(tmp_path / 'out.bin')], + ) + assert sleeps + + def test_filename_to_bytes(tmp_path) -> None: file = tmp_path / 'test' file.write_text('test') @@ -155,10 +188,59 @@ def __init__(self, **kwargs) -> None: self.init_kwargs = kwargs super().__init__(**kwargs) + class RecordingNullBar(progressbar.NullBar): + def __init__(self, **kwargs) -> None: + created.append(self) + self.init_kwargs = kwargs + super().__init__(**kwargs) + monkeypatch.setattr(main.progressbar, 'ProgressBar', RecordingProgressBar) + monkeypatch.setattr(main.progressbar, 'NullBar', RecordingNullBar) return created +def test_build_widgets_honors_display_flags() -> None: + parser = main.create_argument_parser() + args = parser.parse_args( + ['--progress', '--timer', '--eta', '--rate', '--bytes'] + ) + widgets = main._build_widgets(args, filesize_available=True) + widget_types = tuple( + type(widget) for widget in widgets if not isinstance(widget, str) + ) + assert progressbar.Percentage in widget_types + assert progressbar.Bar in widget_types + assert progressbar.Timer in widget_types + assert progressbar.AdaptiveETA in widget_types + assert progressbar.FileTransferSpeed in widget_types + assert progressbar.DataSize in widget_types + + +def test_build_widgets_quiet_is_empty() -> None: + parser = main.create_argument_parser() + args = parser.parse_args(['--quiet']) + assert main._build_widgets(args, filesize_available=True) == [] + + +def test_main_quiet_uses_null_bar(tmp_path, recorded_bars) -> None: + file = tmp_path / 'data.bin' + file.write_bytes(b'x' * 16) + main.main(['--quiet', str(file), '-o', str(tmp_path / 'out.bin')]) + + assert isinstance(recorded_bars[0], progressbar.NullBar) + + +def test_numeric_output_uses_line_breaks(tmp_path, recorded_bars) -> None: + file = tmp_path / 'data.bin' + file.write_bytes(b'x' * 16) + main.main(['--numeric', str(file), '-o', str(tmp_path / 'out.bin')]) + assert recorded_bars[0].init_kwargs['line_breaks'] is True + assert any( + isinstance(widget, progressbar.Percentage) + for widget in recorded_bars[0].init_kwargs['widgets'] + ) + + def test_main_passes_widgets(tmp_path, recorded_bars) -> None: # Regression: E2 - the configured widgets were built but never passed # to the progress bar. diff --git a/tests/test_readme_demos.py b/tests/test_readme_demos.py new file mode 100644 index 00000000..8a63b96f --- /dev/null +++ b/tests/test_readme_demos.py @@ -0,0 +1,345 @@ +import re +import sys +import textwrap +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import scripts.render_readme_demos as demos # noqa: E402 + + +def _bar_widths(frames: list[list[str]]) -> list[int]: + return [ + len(match.group('inner')) + for frame in frames + for line in frame + if (match := demos.BAR_RE.search(line)) + ] + + +def _indented_snippet(snippet: str) -> str: + return '\n'.join( + f' {line}' if line else '' + for line in textwrap.dedent(snippet).strip().splitlines() + ) + + +def _readme_demo_block(demo: demos.Demo, alt: str) -> str: + return ( + f'.. image:: docs/_static/progressbar-{demo.name}.svg\n' + f' :alt: {alt}\n\n' + '.. code:: python\n\n' + f'{_indented_snippet(demo.snippet)}' + ) + + +def test_render_svg_escapes_terminal_text(tmp_path: Path) -> None: + output = tmp_path / 'demo.svg' + demos.render_svg( + output, + title='Demo', + frames=[ + ['', 'progress 0%'], + ['done & clean', 'progress 100%'], + ], + ) + text = output.read_text(encoding='utf-8') + assert '<start>' in text + assert 'done & clean' in text + assert ' None: + output = tmp_path / 'demo.svg' + demos.render_svg( + output, + title='Demo', + frames=[ + [ + 'Build: 50% (1 of 2) |## | ETA: 0:00:00 loss=0.5', + 'log: completed step 1', + ], + ], + ) + + text = output.read_text(encoding='utf-8') + assert 'class="terminal-label">Build:' in text + assert 'class="terminal-percent">50%' in text + assert 'class="terminal-bar-fill">##' in text + assert 'class="terminal-bar-empty"> ' in text + assert 'class="terminal-postfix">loss=0.5' in text + assert 'class="terminal-log">log:' in text + + +def test_render_svg_preserves_actual_ansi_truecolor_segments( + tmp_path: Path, +) -> None: + output = tmp_path / 'demo.svg' + demos.render_svg( + output, + title='Demo', + frames=[ + ['\x1b[38;2;255;0;0m 0%\x1b[39m \x1b[38;2;0;255;0m100%\x1b[39m'], + ], + ) + + text = output.read_text(encoding='utf-8') + assert 'style="fill: #ff0000"> 0%' in text + assert 'style="fill: #00ff00">100%' in text + assert 'class="terminal-percent"' not in text + assert '\x1b' not in text + + +def test_styled_terminal_line_keeps_spinner_pipe_outside_bar() -> None: + styled = demos.styled_terminal_line('| |# | 31 Elapsed Time: 0:00:00') + + assert styled.startswith( + '| |' + '#' + ) + spinner_bar = ( + 'terminal-bar-empty"> ' + ) + assert spinner_bar not in styled + + +def test_demo_definitions_are_ordered_and_exercise_key_features() -> None: + assert [(demo.name, demo.title) for demo in demos.DEMOS] == [ + ('hero', 'Progress with clean logs'), + ('multibar', 'Multiple active jobs'), + ('unknown-length', 'Unknown length'), + ] + + snippets = {demo.name: demo.snippet for demo in demos.DEMOS} + assert 'redirect_stdout=True' in snippets['hero'] + assert 'progressbar.MultiBar' in snippets['multibar'] + assert 'io.StringIO()' in snippets['multibar'] + assert 'fd.getvalue()' in snippets['multibar'] + assert '.fd.line' not in snippets['multibar'] + assert 'build: {build.value}/3' not in snippets['multibar'] + assert 'progressbar.UnknownLength' in snippets['unknown-length'] + assert 'for value in range(0, 120, 10):' in snippets['unknown-length'] + assert 'for value in (' not in snippets['unknown-length'] + + +def test_readme_uses_branch_relative_demo_assets() -> None: + readme = (demos.ROOT / 'README.rst').read_text(encoding='utf-8') + + assert ( + 'raw.githubusercontent.com/WoLpH/python-progressbar/develop' + not in readme + ) + assert '.. image:: docs/_static/progressbar-hero.svg' in readme + assert '.. image:: docs/_static/progressbar-multibar.svg' in readme + assert '.. image:: docs/_static/progressbar-unknown-length.svg' in readme + assert 'progressbar-ergonomics.svg' not in readme + assert 'Tqdm-style ergonomic options' not in readme + + +def test_readme_omits_obsolete_gpg_release_verification() -> None: + readme = (demos.ROOT / 'README.rst').read_text(encoding='utf-8') + + assert 'Release verification' not in readme + assert 'GPG' not in readme + assert 'pgp.mit.edu' not in readme + assert '.tar.gz.asc' not in readme + + +def test_readme_places_exact_demo_code_after_each_animation() -> None: + readme = (demos.ROOT / 'README.rst').read_text(encoding='utf-8') + alt_by_name = { + 'hero': 'progressbar2 showing clean progress output with logs', + 'multibar': 'multiple progress bars updating together', + 'unknown-length': 'unknown length progress with an animated marker', + } + + for demo in demos.DEMOS: + assert _readme_demo_block(demo, alt_by_name[demo.name]) in readme + + +def test_render_svg_first_frame_is_visible_without_animation( + tmp_path: Path, +) -> None: + output = tmp_path / 'demo.svg' + demos.render_svg( + output, + title='Demo', + frames=[ + ['first'], + ['second'], + ], + ) + + text = output.read_text(encoding='utf-8') + assert text.count('') == 1 + assert text.count('') == 1 + assert ' None: + output = tmp_path / 'demo.svg' + demos.render_svg(output, title='Demo', frames=[['a'], ['b'], ['c']]) + + text = output.read_text(encoding='utf-8') + assert 'dur="0.24s"' in text + assert 'dur="3.6s"' not in text + assert '3.599999999' not in text + + +def test_render_svg_uses_wide_canvas_for_readable_bars( + tmp_path: Path, +) -> None: + output = tmp_path / 'demo.svg' + demos.render_svg(output, title='Demo', frames=[['a']]) + + text = output.read_text(encoding='utf-8') + assert 'width="1080"' in text + assert 'viewBox="0 0 1080 96"' in text + + +def test_multibar_demo_captures_rendered_progressbar_output() -> None: + demo = next(demo for demo in demos.DEMOS if demo.name == 'multibar') + frames = demos.capture_demo(demo) + text = '\n'.join(line for frame in frames for line in frame) + + assert frames + assert all(len(frame) == 2 for frame in frames) + assert 'build: 1/3 | test: 1/3' not in text + assert 'build' in text + assert 'test' in text + assert 'Elapsed Time:' in text + assert 'ETA:' in text + assert '(0 of 24)' in text + assert '(1 of 24)' in text or '(2 of 24)' in text + assert '(24 of 24)' in text + + +def test_multibar_demo_shows_independent_progress_values() -> None: + demo = next(demo for demo in demos.DEMOS if demo.name == 'multibar') + frames = demos.capture_demo(demo) + + mismatched_values = [] + for frame in frames: + values = [ + int(match.group(1)) + for line in frame + if (match := re.search(r'\((\d+) of 24\)', line)) + ] + if len(values) == 2 and values[0] != values[1]: + mismatched_values.append(values) + + assert mismatched_values + + +def test_capture_demo_timeout_raises_clear_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_timeout(*args: object, **kwargs: object) -> object: + raise demos.subprocess.TimeoutExpired(cmd=['python'], timeout=5) + + demo = demos.Demo('sample', 'Sample', '') + monkeypatch.setattr(demos.subprocess, 'run', raise_timeout) + + with pytest.raises(SystemExit) as error: + demos.capture_demo(demo) + + assert str(error.value) == 'timed out capturing demo: sample' + + +def test_capture_frames_splits_carriage_return_output() -> None: + frames = demos.frames_from_output('zero\rone\ntwo\rthree') + assert frames == [['zero'], ['one'], ['two'], ['three']] + + +def test_capture_frames_keeps_more_animation_states() -> None: + frames = demos.frames_from_output( + '\n'.join(f'frame {index}' for index in range(30)) + ) + + assert len(frames) == 24 + assert frames[0] == ['frame 0'] + assert frames[-1] == ['frame 29'] + + +def test_readme_demos_emit_enough_frames_to_look_responsive() -> None: + frame_counts = { + demo.name: len(demos.capture_demo(demo)) for demo in demos.DEMOS + } + + assert frame_counts['hero'] == 24 + assert frame_counts['multibar'] == 24 + assert frame_counts['unknown-length'] >= 8 + + +def test_readme_demos_show_wide_determinate_progress_bars() -> None: + frames_by_name = { + demo.name: demos.capture_demo(demo) for demo in demos.DEMOS + } + + for name in ('hero', 'multibar'): + text = '\n'.join( + line for frame in frames_by_name[name] for line in frame + ) + first_frame = '\n'.join(frames_by_name[name][0]) + last_frame = '\n'.join(frames_by_name[name][-1]) + assert '0%' in first_frame + assert '100%' in last_frame + assert '0%' in text + assert '100%' in text + assert max(_bar_widths(frames_by_name[name])) >= 32 + + +def test_readme_demos_capture_real_percentage_color_changes() -> None: + demo = next(demo for demo in demos.DEMOS if demo.name == 'hero') + text = '\n'.join( + line for frame in demos.capture_demo(demo) for line in frame + ) + + assert '\x1b[38;2;255;0;0m' in text + assert '\x1b[38;2;0;255;0m' in text + + +def test_hero_demo_keeps_recent_logs_visible_above_progress() -> None: + demo = next(demo for demo in demos.DEMOS if demo.name == 'hero') + frames = demos.capture_demo(demo) + + assert max(len(frame) for frame in frames) >= 3 + assert any( + any(line.startswith('log: completed step 8') for line in frame) + and any('Build:' in line for line in frame) + for frame in frames + ) + + +def test_capture_frames_normalizes_variable_timing_text() -> None: + frames = demos.frames_from_output( + 'Build: 50% Elapsed Time: 0:01:23 ETA: 9:08:07\n' + 'Build: 100% Elapsed Time: 0:00:04 Time: 2:03:04' + ) + assert frames == [ + ['Build: 50% Elapsed Time: 0:00:00 ETA: 0:00:00'], + ['Build: 100% Elapsed Time: 0:00:00 Time: 0:00:00'], + ] + + +def test_check_mode_does_not_rewrite_mismatched_asset( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + demo = demos.Demo('sample', 'Sample', '') + output = tmp_path / 'progressbar-sample.svg' + output.write_text('stale asset', encoding='utf-8') + + monkeypatch.setattr(demos, 'DEMOS', [demo]) + monkeypatch.setattr(demos, 'STATIC_DIR', tmp_path) + monkeypatch.setattr(demos, 'capture_demo', lambda demo: [['fresh asset']]) + monkeypatch.setattr(sys, 'argv', ['render_readme_demos.py', '--check']) + + with pytest.raises(SystemExit) as error: + demos.main() + + assert 'outdated generated asset' in str(error.value) + assert output.read_text(encoding='utf-8') == 'stale asset' diff --git a/tests/test_stream.py b/tests/test_stream.py index 194310c2..8f1b97ed 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -1,4 +1,5 @@ import io +import logging import os import sys @@ -8,10 +9,28 @@ from progressbar import terminal +def reset_wrapped_streams() -> None: + while progressbar.streams.wrapped_logging: + progressbar.streams.unwrap_logging() + while ( + progressbar.streams.wrapped_stdout + or progressbar.streams.wrapped_stderr + ): + progressbar.streams.unwrap(stderr=True, stdout=True) + progressbar.streams.wrapped_logging = 0 + progressbar.streams.wrapped_stdout = 0 + progressbar.streams.wrapped_stderr = 0 + progressbar.streams.logging_handlers.clear() + for listener in list(progressbar.streams.listeners): + listener._finished = True + progressbar.streams.listeners.clear() + progressbar.streams.capturing = 0 + progressbar.streams.update_capturing() + + def test_nowrap() -> None: # Make sure we definitely unwrap - for _i in range(5): - progressbar.streams.unwrap(stderr=True, stdout=True) + reset_wrapped_streams() stdout = sys.stdout stderr = sys.stderr @@ -27,14 +46,12 @@ def test_nowrap() -> None: assert stderr == sys.stderr # Make sure we definitely unwrap - for _i in range(5): - progressbar.streams.unwrap(stderr=True, stdout=True) + reset_wrapped_streams() def test_wrap() -> None: # Make sure we definitely unwrap - for _i in range(5): - progressbar.streams.unwrap(stderr=True, stdout=True) + reset_wrapped_streams() stdout = sys.stdout stderr = sys.stderr @@ -54,8 +71,253 @@ def test_wrap() -> None: assert stderr == sys.stderr # Make sure we definitely unwrap - for _i in range(5): - progressbar.streams.unwrap(stderr=True, stdout=True) + reset_wrapped_streams() + + +def test_wrap_logging_retargets_existing_stderr_handler(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + monkeypatch.setattr(sys, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-wrap-logging') + logger.handlers = [] + logger.propagate = False + handler = logging.StreamHandler(sys.stderr) + logger.addHandler(handler) + + progressbar.streams.wrap_stderr() + progressbar.streams.wrap_logging() + try: + assert handler.stream is progressbar.streams.stderr + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_unwrap_logging_restores_handler_stream(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-unwrap-logging') + logger.handlers = [] + logger.propagate = False + handler = logging.StreamHandler(sys.stderr) + logger.addHandler(handler) + + progressbar.streams.wrap_stderr() + progressbar.streams.wrap_logging() + progressbar.streams.unwrap_logging() + + try: + assert handler.stream is stream + finally: + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_unwrap_logging_restores_handler_created_after_stderr_wrap( + monkeypatch, +) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-wrapped-stderr-handler') + logger.handlers = [] + logger.propagate = False + + progressbar.streams.wrap_stderr() + wrapped_stderr = progressbar.streams.stderr + handler = logging.StreamHandler(sys.stderr) + logger.addHandler(handler) + + try: + assert handler.stream is wrapped_stderr + + progressbar.streams.wrap_logging() + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + + assert handler.stream is stream + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_wrap_logging_handles_nested_calls(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-nested-wrap-logging') + logger.handlers = [] + logger.propagate = False + handler = logging.StreamHandler(sys.stderr) + logger.addHandler(handler) + + progressbar.streams.wrap_stderr() + progressbar.streams.wrap_logging() + progressbar.streams.wrap_logging() + try: + assert progressbar.streams.wrapped_logging == 2 + progressbar.streams.unwrap_logging() + assert progressbar.streams.wrapped_logging == 1 + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_wrap_logging_retargets_existing_stdout_handler(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stdout', stream) + monkeypatch.setattr(progressbar.streams, 'original_stdout', stream) + monkeypatch.setattr(progressbar.streams, 'stdout', stream) + + logger = logging.getLogger('progressbar-test-wrap-stdout-logging') + logger.handlers = [] + logger.propagate = False + handler = logging.StreamHandler(sys.stdout) + logger.addHandler(handler) + + progressbar.streams.wrap_stdout() + progressbar.streams.wrap_logging() + try: + assert handler.stream is progressbar.streams.stdout + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stdout=True) + logger.handlers = [] + + +def test_wrap_logging_ignores_handlers_that_reject_streams( + monkeypatch, +) -> None: + class RejectingHandler(logging.StreamHandler): + def setStream(self, stream): # noqa: N802 + raise ValueError('stream rejected') + + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-rejecting-handler') + logger.handlers = [] + logger.propagate = False + handler = RejectingHandler(sys.stderr) + logger.addHandler(handler) + + progressbar.streams.wrap_stderr() + try: + progressbar.streams.wrap_logging() + assert all( + logged_handler is not handler + for logged_handler, _stream in progressbar.streams.logging_handlers + ) + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_unwrap_logging_ignores_dynamic_stderr_handler(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-dynamic-stderr-handler') + logger.handlers = [] + logger.propagate = False + handler = logging._StderrHandler() # type: ignore[attr-defined] + logger.addHandler(handler) + + try: + progressbar.streams.wrap_stderr() + assert handler.stream is progressbar.streams.stderr + + progressbar.streams.wrap_logging() + progressbar.streams.unwrap_logging() + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_redirected_stdout_lines_are_flushed_above_bar(monkeypatch) -> None: + reset_wrapped_streams() + output = io.StringIO() + monkeypatch.setattr(progressbar.streams, 'original_stdout', output) + monkeypatch.setattr(progressbar.streams, 'stdout', output) + monkeypatch.setattr(sys, 'stdout', output) + + with progressbar.ProgressBar( + max_value=2, + fd=output, + redirect_stdout=True, + line_breaks=False, + is_terminal=True, + term_width=40, + ) as bar: + print('phase one') + bar.update(1, force=True) + print('phase two') + bar.update(2, force=True) + + rendered = output.getvalue() + assert 'phase one' in rendered + assert 'phase two' in rendered + assert '\r' + ' ' * 40 + '\rphase two\n' in rendered + assert rendered.endswith('\n') + + +def test_redirected_stderr_lines_are_flushed_above_bar(monkeypatch) -> None: + reset_wrapped_streams() + output = io.StringIO() + monkeypatch.setattr(progressbar.streams, 'original_stderr', output) + monkeypatch.setattr(progressbar.streams, 'stderr', output) + monkeypatch.setattr(sys, 'stderr', output) + + with progressbar.ProgressBar( + max_value=2, + fd=output, + redirect_stderr=True, + line_breaks=False, + is_terminal=True, + term_width=40, + ) as bar: + print('warning one', file=sys.stderr) + bar.update(1, force=True) + print('warning two', file=sys.stderr) + bar.update(2, force=True) + + rendered = output.getvalue() + assert 'warning one' in rendered + assert 'warning two' in rendered + assert '\r' + ' ' * 40 + '\rwarning two\n' in rendered + assert rendered.endswith('\n') def test_excepthook() -> None: diff --git a/tests/test_widgets.py b/tests/test_widgets.py index af4f1a33..942bc7da 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -65,6 +65,92 @@ def test_widgets_large_values(max_value) -> None: p.finish() +def test_postfix_widget_renders_mapping_sorted() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': {'z': 2, 'a': 1}}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == ' a=1, z=2' + + +def test_postfix_widget_renders_string() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': 'loss=0.25'}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == ' loss=0.25' + + +def test_postfix_widget_renders_other_values() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': 3}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == ' 3' + + +def test_postfix_widget_omits_empty_values() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': None}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == '' + + +def test_format_unit_value_special_cases() -> None: + assert progressbar.widgets.format_unit_value(None) == 'N/A' + unknown_value = progressbar.widgets.format_unit_value( + progressbar.UnknownLength, + ) + assert unknown_value == 'N/A' + assert progressbar.widgets.format_unit_value(1.5, unit='B') == '1.5 B' + assert progressbar.widgets.format_unit_value(2, unit='B') == '2 B' + + +def test_unit_progress_scales_values() -> None: + bar = progressbar.ProgressBar( + max_value=2048, + widgets=[progressbar.UnitProgress(unit='B', unit_scale=True)], + fd=io.StringIO(), + term_width=80, + ) + bar.start() + bar.update(1024, force=True) + output = ''.join(bar._format_widgets()) + assert output == '1.0 KiB of 2.0 KiB' + + +def test_unit_progress_uses_progress_units_by_default() -> None: + bar = progressbar.ProgressBar( + max_value=2048, + widgets=[progressbar.UnitProgress()], + unit='B', + unit_scale=True, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + bar.update(1024, force=True) + output = ''.join(bar._format_widgets()) + assert output == '1.0 KiB of 2.0 KiB' + + def test_format_widget() -> None: widgets = [ progressbar.FormatLabel(f'%({mapping})r') From 07ac08a442e2ea71e7d22f6926d6cf435b2b91e2 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Tue, 23 Jun 2026 15:33:23 +0200 Subject: [PATCH 2/2] Address progress review cleanup issues --- progressbar/bar.py | 3 ++- progressbar/utils.py | 2 +- progressbar/widgets.py | 4 +++- tests/test_progressbar.py | 22 +++++++++++++++++++- tests/test_stream.py | 42 +++++++++++++++++++++++++++++++++++++++ tests/test_widgets.py | 20 +++++++++++++++++++ 6 files changed, 89 insertions(+), 4 deletions(-) diff --git a/progressbar/bar.py b/progressbar/bar.py index 1d480856..9a5ccd5c 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -1166,7 +1166,8 @@ def start( StdRedirectMixin.finish(self, end='') with contextlib.suppress(Exception): ResizableMixin.finish(self) - ProgressBarBase.finish(self) + with contextlib.suppress(Exception): + ProgressBarBase.finish(self) raise return self diff --git a/progressbar/utils.py b/progressbar/utils.py index 47a07876..42d7ca0a 100644 --- a/progressbar/utils.py +++ b/progressbar/utils.py @@ -342,7 +342,7 @@ def wrap_logging(self) -> None: seen: set[int] = set() for logger_ in self._iter_loggers(): - for handler in logger_.handlers: + for handler in tuple(logger_.handlers): if id(handler) in seen: continue seen.add(id(handler)) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 9aaa229e..5a7ca1d3 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1266,7 +1266,9 @@ def __init__( def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: value = data['variables'].get(self.name) - if not value: + if value is None or ( + isinstance(value, (str, dict, list, set, tuple)) and not value + ): return '' if isinstance(value, str): rendered = value diff --git a/tests/test_progressbar.py b/tests/test_progressbar.py index 207d78f7..7d41e087 100644 --- a/tests/test_progressbar.py +++ b/tests/test_progressbar.py @@ -11,7 +11,10 @@ import pytest import progressbar -from progressbar import utils +from progressbar import ( + bar as bar_module, + utils, +) # Import hack to allow for parallel Tox try: @@ -262,6 +265,23 @@ def test_start_cleans_stream_listener_when_validation_fails() -> None: assert bar not in utils.streams.listeners +def test_start_preserves_original_error_when_base_cleanup_fails( + monkeypatch, +) -> None: + def fail_start(self, max_value=None): + raise ValueError('resize start failed') + + def fail_finish(self): + raise RuntimeError('base cleanup failed') + + monkeypatch.setattr(bar_module.ResizableMixin, 'start', fail_start) + monkeypatch.setattr(bar_module.ProgressBarBase, 'finish', fail_finish) + + bar = progressbar.ProgressBar(max_value=5, fd=io.StringIO()) + with pytest.raises(ValueError, match='resize start failed'): + bar.start() + + @pytest.mark.skipif(os.name == 'nt', reason='SIGWINCH is POSIX-only') def test_sigwinch_restored_with_overlapping_bars() -> None: # Regression: A5 - with two live bars, finishing them in creation diff --git a/tests/test_stream.py b/tests/test_stream.py index 8f1b97ed..30bdaa9b 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -240,6 +240,48 @@ def setStream(self, stream): # noqa: N802 logger.handlers = [] +def test_wrap_logging_uses_handler_snapshot(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-mutating-handlers') + logger.handlers = [] + logger.propagate = False + first_handler = logging.StreamHandler(sys.stderr) + late_handler = logging.StreamHandler(sys.stderr) + logger.addHandler(first_handler) + + wrapped_handlers: list[logging.StreamHandler] = [] + original_wrap_handler = progressbar.streams._wrap_logging_handler + + def mutating_wrap_handler(handler, wrapped_streams, restore_streams): + wrapped_handlers.append(handler) + if handler is first_handler: + logger.addHandler(late_handler) + original_wrap_handler(handler, wrapped_streams, restore_streams) + + monkeypatch.setattr( + progressbar.streams, + '_wrap_logging_handler', + mutating_wrap_handler, + ) + + progressbar.streams.wrap_stderr() + try: + progressbar.streams.wrap_logging() + assert first_handler in wrapped_handlers + assert late_handler not in wrapped_handlers + assert late_handler.stream is stream + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + def test_unwrap_logging_ignores_dynamic_stderr_handler(monkeypatch) -> None: reset_wrapped_streams() diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 942bc7da..b56e267e 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -101,6 +101,26 @@ def test_postfix_widget_renders_other_values() -> None: assert output == ' 3' +@pytest.mark.parametrize( + ('value', 'expected'), + [ + (0, ' 0'), + (0.0, ' 0.0'), + (False, ' False'), + ], +) +def test_postfix_widget_renders_falsy_values(value, expected) -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': value}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == expected + + def test_postfix_widget_omits_empty_values() -> None: bar = progressbar.ProgressBar( widgets=[progressbar.Postfix()],