diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 943c74da..00000000 --- a/.coveragerc +++ /dev/null @@ -1,15 +0,0 @@ -[run] -branch = True -source = - progressbar - tests -omit = - */mock/* - */nose/* -[paths] -source = - progressbar -[report] -exclude_lines = - pragma: no cover - @abc.abstractmethod diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..5c0820ff --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: WoLpH diff --git a/.github/ci-reporter.yml b/.github/ci-reporter.yml new file mode 100644 index 00000000..11114586 --- /dev/null +++ b/.github/ci-reporter.yml @@ -0,0 +1,8 @@ +# Set to false to create a new comment instead of updating the app's first one +updateComment: true + +# Use a custom string, or set to false to disable +before: "✨ Good work on this PR so far! ✨ Unfortunately, the [ build]() is failing as of . Here's the output:" + +# Use a custom string, or set to false to disable +after: "I'm sure you can fix it! If you need help, don't hesitate to ask a maintainer of the project!" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..44ccfb21 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: "CodeQL" + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + schedule: + - cron: "24 21 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python, javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + if: ${{ matrix.language == 'python' || matrix.language == 'javascript' }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..ddac4b2a --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,26 @@ +name: tox + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11'] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip tox + - name: Test with tox + run: tox diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..5c47a9d7 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,17 @@ +name: Close stale issues and pull requests + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' # Run every day at midnight + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v8 + with: + days-before-issue-stale: 30 + exempt-issue-labels: in-progress,help-wanted,pinned,security,enhancement + exempt-all-assignees: true + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..e226ea93 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.4.0 + hooks: + - id: trailing-whitespace + - id: check-yaml + - id: check-added-large-files + +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.9 + hooks: + - id: flake8 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..bee434db --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,35 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.12" + # You can also specify other tool versions: + # nodejs: "20" + # rust: "1.70" + # golang: "1.20" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs + # builder: "dirhtml" + # Fail on all warnings to avoid broken references + # fail_on_warning: true + +# Optionally build your docs in additional formats such as PDF and ePub +formats: + - pdf + - epub + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 01ccdc9a..00000000 --- a/.travis.yml +++ /dev/null @@ -1,18 +0,0 @@ -language: python -python: - - "2.6" - - "2.7" - - "3.2" - - "3.3" - - "pypy" -# command to install dependencies -install: - - pip install . --use-mirrors - - pip install nose coverage tissue coveralls --use-mirrors -# command to run tests -script: - - nosetests --with-coverage - -after_success: - - coveralls - diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 00000000..ebb9d853 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,11 @@ +========= +Changelog +========= + +For the most recent changes to the Python Progressbar please look at the Git +releases or the commit log: + + - https://github.com/WoLpH/python-progressbar/releases + - https://github.com/WoLpH/python-progressbar/commits/develop + +Hint: click on the `...` button to see the change message. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..3aa38b88 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,126 @@ +============ +Contributing +============ + +Contributions are welcome, and they are greatly appreciated! Every +little bit helps, and credit will always be given. + +You can contribute in many ways: + +Types of Contributions +---------------------- + +Report Bugs +~~~~~~~~~~~ + +Report bugs at https://github.com/WoLpH/python-progressbar/issues. + +If you are reporting a bug, please include: + +* Your operating system name and version. +* Any details about your local setup that might be helpful in troubleshooting. +* Detailed steps to reproduce the bug. + +Fix Bugs +~~~~~~~~ + +Look through the GitHub issues for bugs. Anything tagged with "bug" +is open to whoever wants to implement it. + +Implement Features +~~~~~~~~~~~~~~~~~~ + +Look through the GitHub issues for features. Anything tagged with "feature" +is open to whoever wants to implement it. + +Write Documentation +~~~~~~~~~~~~~~~~~~~ + +Python Progressbar could always use more documentation, whether as part of the +official Python Progressbar docs, in docstrings, or even on the web in blog posts, +articles, and such. + +Submit Feedback +~~~~~~~~~~~~~~~ + +The best way to send feedback is to file an issue at https://github.com/WoLpH/python-progressbar/issues. + +If you are proposing a feature: + +* Explain in detail how it would work. +* Keep the scope as narrow as possible, to make it easier to implement. +* Remember that this is a volunteer-driven project, and that contributions + are welcome :) + +Get Started! +------------ + +Ready to contribute? Here's how to set up `python-progressbar` for local development. + +1. Fork the `python-progressbar` repo on GitHub. +2. Clone your fork locally:: + + $ git clone --branch develop git@github.com:your_name_here/python-progressbar.git + +3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: + + $ mkvirtualenv progressbar + $ cd progressbar/ + $ pip install -e . + +4. Create a branch for local development with `git-flow-avh`_:: + + $ git-flow feature start name-of-your-bugfix-or-feature + + Or without git-flow: + + $ git checkout -b feature/name-of-your-bugfix-or-feature + + Now you can make your changes locally. + +5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: + + $ flake8 progressbar tests + $ py.test + $ tox + + To get flake8 and tox, just pip install them into your virtualenv using the requirements file. + + $ pip install -r tests/requirements.txt + +6. Commit your changes and push your branch to GitHub with `git-flow-avh`_:: + + $ git add . + $ git commit -m "Your detailed description of your changes." + $ git-flow feature publish + + Or without git-flow: + + $ git add . + $ git commit -m "Your detailed description of your changes." + $ git push -u origin feature/name-of-your-bugfix-or-feature + +7. Submit a pull request through the GitHub website. + +Pull Request Guidelines +----------------------- + +Before you submit a pull request, check that it meets these guidelines: + +1. The pull request should include tests. +2. If the pull request adds functionality, the docs should be updated. Put + your new functionality into a function with a docstring, and add the + feature to the list in README.rst. +3. The pull request should work for Python 2.7, 3.3, and for PyPy. Check + https://travis-ci.org/WoLpH/python-progressbar/pull_requests + and make sure that the tests pass for all supported Python versions. + +Tips +---- + +To run a subset of tests:: + + $ py.test tests/some_test.py + +.. _git-flow-avh: https://github.com/petervanderdoes/gitflow + diff --git a/ChangeLog.yaml b/ChangeLog.yaml deleted file mode 100644 index 01da7e42..00000000 --- a/ChangeLog.yaml +++ /dev/null @@ -1,66 +0,0 @@ -2011-05-15: - - Removed parse errors for Python2.4 (no, people *should not* be using it - but it is only 3 years old and it does not have that many differences) - - - split up progressbar.py into logical units while maintaining backwards - compatability - - - Removed MANIFEST.in because it is no longer needed and it was causing - distribute to show warnings - - -2011-05-14: - - Changes to directory structure so pip can install from Google Code - - Python 3.x related fixes (all examples work on Python 3.1.3) - - Added counters, timers, and action bars for iterators with unknown length - -2010-08-29: - - Refactored some code and made it possible to use a ProgressBar as - an iterator (actually as an iterator that is a proxy to another iterator). - This simplifies showing a progress bar in a number of cases. - -2010-08-15: - - Did some minor changes to make it compatible with python 3. - -2009-05-31: - - Included check for calling start before update. - -2009-03-21: - - Improved FileTransferSpeed widget, which now supports an unit parameter, - defaulting to 'B' for bytes. It will also show B/s, MB/s, etc instead of - B/s, M/s, etc. - -2009-02-24: - - Updated licensing. - - Moved examples to separated file. - - Improved _need_update() method, which is now as fast as it can be. IOW, - no wasted cycles when an update is not needed. - -2008-12-22: - - Added SimpleProgress widget contributed by Sando Tosi - . - -2006-05-07: - - Fixed bug with terminal width in Windows. - - Released version 2.2. - -2005-12-04: - - Autodetection of terminal width. - - Added start method. - - Released version 2.1. - -2005-12-04: - - Everything is a widget now! - - Released version 2.0. - -2005-12-03: - - Rewrite using widgets. - - Released version 1.0. - -2005-06-02: - - Rewrite. - - Released version 0.5. - -2004-06-15: - - First version. - - Released version 0.1. diff --git a/ISSUE_TEMPLATE b/ISSUE_TEMPLATE new file mode 100644 index 00000000..ac4e32d4 --- /dev/null +++ b/ISSUE_TEMPLATE @@ -0,0 +1,15 @@ +#### Description + +Description of the problem + +#### Code + +If applicable, code to reproduce the issue and/or the stacktrace of the issue + +#### Versions + +- Python version: `import sys; print(sys.version)` +- Python distribution/environment: CPython/Anaconda/IPython/IDLE +- Operating System: Windows 10, Ubuntu Linux, etc. +- Package version: `import progressbar; print(progressbar.__version__)` + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..38887b7c --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2022, Rick van Hattem (Wolph) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of Python Progressbar nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index fc8ccdc1..00000000 --- a/LICENSE.txt +++ /dev/null @@ -1,52 +0,0 @@ -You can redistribute and/or modify this library under the terms of the -GNU LGPL license or BSD license (or both). - ---- - -progressbar - Text progress bar library for python. -Copyright (C) 2005 Nilton Volpato - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation; either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - ---- - -progressbar - Text progress bar library for python -Copyright (c) 2008 Nilton Volpato - -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - a. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - b. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - c. Neither the name of the author nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY -OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH -DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in index a1d7b673..f387924e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,12 @@ -include README.txt LICENSE.txt +recursive-exclude *.pyc +recursive-exclude *.pyo +recursive-exclude *.html +include AUTHORS.rst +include CHANGES.rst +include CONTRIBUTING.rst +include LICENSE +include README.rst include examples.py +include requirements.txt +include Makefile +include pytest.ini diff --git a/README.rst b/README.rst index 552d5ea6..ec4f7b9a 100644 --- a/README.rst +++ b/README.rst @@ -1,41 +1,353 @@ +############################################################################## Text progress bar library for Python. ------------------------------------------------------------------------------- +############################################################################## -Travis status: +Build status: -.. image:: https://travis-ci.org/WoLpH/python-progressbar.png?branch=master - :target: https://travis-ci.org/WoLpH/python-progressbar +.. image:: https://github.com/WoLpH/python-progressbar/actions/workflows/main.yml/badge.svg + :alt: python-progressbar test status + :target: https://github.com/WoLpH/python-progressbar/actions Coverage: -.. image:: https://coveralls.io/repos/WoLpH/python-progressbar/badge.png?branch=master +.. image:: https://coveralls.io/repos/WoLpH/python-progressbar/badge.svg?branch=master :target: https://coveralls.io/r/WoLpH/python-progressbar?branch=master +****************************************************************************** +Install +****************************************************************************** + +The package can be installed through `pip` (this is the recommended method): + + 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 three types +differently depending on the state of the progress bar. There are many types of widgets: - - a string, which always shows itself - - - a ProgressBarWidget, which may return a different value every time its - update method is called - - - a ProgressBarWidgetHFill, which is like ProgressBarWidget, except it - expands to fill the remaining width of the line. + - `AbsoluteETA `_ + - `AdaptiveETA `_ + - `AdaptiveTransferSpeed `_ + - `AnimatedMarker `_ + - `Bar `_ + - `BouncingBar `_ + - `Counter `_ + - `CurrentTime `_ + - `DataSize `_ + - `DynamicMessage `_ + - `ETA `_ + - `FileTransferSpeed `_ + - `FormatCustomText `_ + - `FormatLabel `_ + - `FormatLabelBar `_ + - `GranularBar `_ + - `Percentage `_ + - `PercentageLabelBar `_ + - `ReverseBar `_ + - `RotatingMarker `_ + - `SimpleProgress `_ + - `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. -To install: -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +****************************************************************************** +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 +============================================================================== +.. code:: python + + import time + import progressbar + + for i in progressbar.progressbar(range(100)): + time.sleep(0.02) + +Progressbars with logging +============================================================================== + +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. + +.. code:: python + + 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 +============================================================================== + +.. code:: python + + import random + import threading + import time + + 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 +============================================================================== +.. code:: python + + import time + import progressbar + + 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 progressbar + + for i in progressbar.progressbar(range(100), redirect_stdout=True): + print('Some text', i) + time.sleep(0.1) + +Progressbar with unknown length +============================================================================== +.. code:: python + + import time + import progressbar + + bar = progressbar.ProgressBar(max_value=progressbar.UnknownLength) + for i in range(20): + time.sleep(0.1) + bar.update(i) + +Bar with custom widgets +============================================================================== +.. code:: python + + import time + import progressbar + + 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 +============================================================================== + +.. code:: python + + # vim: fileencoding=utf-8 + import time + import progressbar + + + 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 +============================================================================== + +.. 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) + + # The progress bar updates, normally you would do something useful here + for i in range(N * BARS): + time.sleep(0.005) + + # Increment one of the progress bars at random + bars[random.randrange(0, BARS)].increment() + + # 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) - pip install progressbar2 -Documentation: -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + for bar in bars: + Worker(bar).start() -http://progressbar-2.readthedocs.org/en/latest/ + print() diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000..fa5f5a53 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,41 @@ +# What Python version is installed where: +# http://www.appveyor.com/docs/installed-software#python + +environment: + matrix: + - PYTHON: "C:\\Python27" + TOX_ENV: "py27" + + - PYTHON: "C:\\Python34" + TOX_ENV: "py34" + + - PYTHON: "C:\\Python35" + TOX_ENV: "py35" + + - PYTHON: "C:\\Python36" + TOX_ENV: "py36" + + +init: + - "%PYTHON%/python -V" + - "%PYTHON%/python -c \"import struct;print( 8 * struct.calcsize(\'P\'))\"" + +install: + - "%PYTHON%/Scripts/easy_install -U pip" + - "%PYTHON%/Scripts/pip install tox" + - "%PYTHON%/Scripts/pip install wheel" + +build: false # Not a C# project, build stuff at the test step instead. + +test_script: + - "%PYTHON%/Scripts/tox -e %TOX_ENV%" + +after_test: + - "%PYTHON%/python setup.py bdist_wheel" + - ps: "ls dist" + +artifacts: + - path: dist\* + +#on_success: +# - TODO: upload the content of dist/*.whl to a public wheelhouse diff --git a/circle.yml b/circle.yml index bccb4f26..cd50d56f 100644 --- a/circle.yml +++ b/circle.yml @@ -1,19 +1,8 @@ dependencies: - cache_directories: - - .pip-cache - - .pyenv - pre: - - pyenv shell 2.6.8; $(pyenv which pip) install --download-cache .pip-cache nose coverage tissue - - pyenv shell 2.7; $(pyenv which pip) install --download-cache .pip-cache nose coverage tissue - - pyenv shell 3.3.3; $(pyenv which pip) install --download-cache .pip-cache nose coverage tissue - - pyenv shell 3.4.1; $(pyenv which pip) install --download-cache .pip-cache nose coverage tissue - - pyenv shell pypy-2.2.1; $(pyenv which pip) install --download-cache .pip-cache nose coverage tissue + - pip install -r tests/requirements.txt test: override: - - pyenv shell 2.6.8; $(pyenv which nosetests) - - pyenv shell 2.7; $(pyenv which nosetests) - - pyenv shell 3.3.3; $(pyenv which nosetests) - - pyenv shell 3.4.1; $(pyenv which nosetests) - - pyenv shell pypy-2.2.1; $(pyenv which nosetests) + - py.test + diff --git a/docs/_theme/LICENSE b/docs/_theme/LICENSE index f258ba03..7660d090 100644 --- a/docs/_theme/LICENSE +++ b/docs/_theme/LICENSE @@ -1,9 +1,9 @@ -Modifications: +Modifications: Copyright (c) 2012 Rick van Hattem. -Original Projects: +Original Projects: Copyright (c) 2010 Kenneth Reitz. Copyright (c) 2010 by Armin Ronacher. diff --git a/docs/_theme/flask_theme_support.py b/docs/_theme/flask_theme_support.py index 555c116d..81747125 100644 --- a/docs/_theme/flask_theme_support.py +++ b/docs/_theme/flask_theme_support.py @@ -1,86 +1,89 @@ # flasky extensions. flasky pygments style based on tango style from pygments.style import Style -from pygments.token import Keyword, Name, Comment, String, Error, \ - Number, Operator, Generic, Whitespace, Punctuation, Other, Literal +from pygments.token import ( + Comment, + Error, + Generic, + Keyword, + Literal, + Name, + Number, + Operator, + Other, + Punctuation, + String, + Whitespace, +) class FlaskyStyle(Style): - background_color = "#f8f8f8" - default_style = "" + background_color = '#f8f8f8' + default_style = '' styles = { # No corresponding class for the following: - # Text: "", # class: '' - Whitespace: "underline #f8f8f8", # class: 'w' - Error: "#a40000 border:#ef2929", # class: 'err' - Other: "#000000", # class 'x' - - Comment: "italic #8f5902", # class: 'c' - Comment.Preproc: "noitalic", # class: 'cp' - - Keyword: "bold #004461", # class: 'k' - Keyword.Constant: "bold #004461", # class: 'kc' - Keyword.Declaration: "bold #004461", # class: 'kd' - Keyword.Namespace: "bold #004461", # class: 'kn' - Keyword.Pseudo: "bold #004461", # class: 'kp' - Keyword.Reserved: "bold #004461", # class: 'kr' - Keyword.Type: "bold #004461", # class: 'kt' - - Operator: "#582800", # class: 'o' - Operator.Word: "bold #004461", # class: 'ow' - like keywords - - Punctuation: "bold #000000", # class: 'p' - + # Text: '', # class: '' + Whitespace: 'underline #f8f8f8', # class: 'w' + Error: '#a40000 border:#ef2929', # class: 'err' + Other: '#000000', # class 'x' + Comment: 'italic #8f5902', # class: 'c' + Comment.Preproc: 'noitalic', # class: 'cp' + Keyword: 'bold #004461', # class: 'k' + Keyword.Constant: 'bold #004461', # class: 'kc' + Keyword.Declaration: 'bold #004461', # class: 'kd' + Keyword.Namespace: 'bold #004461', # class: 'kn' + Keyword.Pseudo: 'bold #004461', # class: 'kp' + Keyword.Reserved: 'bold #004461', # class: 'kr' + Keyword.Type: 'bold #004461', # class: 'kt' + Operator: '#582800', # class: 'o' + Operator.Word: 'bold #004461', # class: 'ow' - like keywords + Punctuation: 'bold #000000', # class: 'p' # because special names such as Name.Class, Name.Function, etc. # are not recognized as such later in the parsing, we choose them # to look the same as ordinary variables. - Name: "#000000", # class: 'n' - Name.Attribute: "#c4a000", # class: 'na' - to be revised - Name.Builtin: "#004461", # class: 'nb' - Name.Builtin.Pseudo: "#3465a4", # class: 'bp' - Name.Class: "#000000", # class: 'nc' - to be revised - Name.Constant: "#000000", # class: 'no' - to be revised - Name.Decorator: "#888", # class: 'nd' - to be revised - Name.Entity: "#ce5c00", # class: 'ni' - Name.Exception: "bold #cc0000", # class: 'ne' - Name.Function: "#000000", # class: 'nf' - Name.Property: "#000000", # class: 'py' - Name.Label: "#f57900", # class: 'nl' - Name.Namespace: "#000000", # class: 'nn' - to be revised - Name.Other: "#000000", # class: 'nx' - Name.Tag: "bold #004461", # class: 'nt' - like a keyword - Name.Variable: "#000000", # class: 'nv' - to be revised - Name.Variable.Class: "#000000", # class: 'vc' - to be revised - Name.Variable.Global: "#000000", # class: 'vg' - to be revised - Name.Variable.Instance: "#000000", # class: 'vi' - to be revised - - Number: "#990000", # class: 'm' - - Literal: "#000000", # class: 'l' - Literal.Date: "#000000", # class: 'ld' - - String: "#4e9a06", # class: 's' - String.Backtick: "#4e9a06", # class: 'sb' - String.Char: "#4e9a06", # class: 'sc' - String.Doc: "italic #8f5902", # class: 'sd' - like a comment - String.Double: "#4e9a06", # class: 's2' - String.Escape: "#4e9a06", # class: 'se' - String.Heredoc: "#4e9a06", # class: 'sh' - String.Interpol: "#4e9a06", # class: 'si' - String.Other: "#4e9a06", # class: 'sx' - String.Regex: "#4e9a06", # class: 'sr' - String.Single: "#4e9a06", # class: 's1' - String.Symbol: "#4e9a06", # class: 'ss' - - Generic: "#000000", # class: 'g' - Generic.Deleted: "#a40000", # class: 'gd' - Generic.Emph: "italic #000000", # class: 'ge' - Generic.Error: "#ef2929", # class: 'gr' - Generic.Heading: "bold #000080", # class: 'gh' - Generic.Inserted: "#00A000", # class: 'gi' - Generic.Output: "#888", # class: 'go' - Generic.Prompt: "#745334", # class: 'gp' - Generic.Strong: "bold #000000", # class: 'gs' - Generic.Subheading: "bold #800080", # class: 'gu' - Generic.Traceback: "bold #a40000", # class: 'gt' + Name: '#000000', # class: 'n' + Name.Attribute: '#c4a000', # class: 'na' - to be revised + Name.Builtin: '#004461', # class: 'nb' + Name.Builtin.Pseudo: '#3465a4', # class: 'bp' + Name.Class: '#000000', # class: 'nc' - to be revised + Name.Constant: '#000000', # class: 'no' - to be revised + Name.Decorator: '#888', # class: 'nd' - to be revised + Name.Entity: '#ce5c00', # class: 'ni' + Name.Exception: 'bold #cc0000', # class: 'ne' + Name.Function: '#000000', # class: 'nf' + Name.Property: '#000000', # class: 'py' + Name.Label: '#f57900', # class: 'nl' + Name.Namespace: '#000000', # class: 'nn' - to be revised + Name.Other: '#000000', # class: 'nx' + Name.Tag: 'bold #004461', # class: 'nt' - like a keyword + Name.Variable: '#000000', # class: 'nv' - to be revised + Name.Variable.Class: '#000000', # class: 'vc' - to be revised + Name.Variable.Global: '#000000', # class: 'vg' - to be revised + Name.Variable.Instance: '#000000', # class: 'vi' - to be revised + Number: '#990000', # class: 'm' + Literal: '#000000', # class: 'l' + Literal.Date: '#000000', # class: 'ld' + String: '#4e9a06', # class: 's' + String.Backtick: '#4e9a06', # class: 'sb' + String.Char: '#4e9a06', # class: 'sc' + String.Doc: 'italic #8f5902', # class: 'sd' - like a comment + String.Double: '#4e9a06', # class: 's2' + String.Escape: '#4e9a06', # class: 'se' + String.Heredoc: '#4e9a06', # class: 'sh' + String.Interpol: '#4e9a06', # class: 'si' + String.Other: '#4e9a06', # class: 'sx' + String.Regex: '#4e9a06', # class: 'sr' + String.Single: '#4e9a06', # class: 's1' + String.Symbol: '#4e9a06', # class: 'ss' + Generic: '#000000', # class: 'g' + Generic.Deleted: '#a40000', # class: 'gd' + Generic.Emph: 'italic #000000', # class: 'ge' + Generic.Error: '#ef2929', # class: 'gr' + Generic.Heading: 'bold #000080', # class: 'gh' + Generic.Inserted: '#00A000', # class: 'gi' + Generic.Output: '#888', # class: 'go' + Generic.Prompt: '#745334', # class: 'gp' + Generic.Strong: 'bold #000000', # class: 'gs' + Generic.Subheading: 'bold #800080', # class: 'gu' + Generic.Traceback: 'bold #a40000', # class: 'gt' } diff --git a/docs/_theme/wolph/theme.conf b/docs/_theme/wolph/theme.conf index 307a1f0d..07698f6f 100644 --- a/docs/_theme/wolph/theme.conf +++ b/docs/_theme/wolph/theme.conf @@ -4,4 +4,4 @@ stylesheet = flasky.css pygments_style = flask_theme_support.FlaskyStyle [options] -touch_icon = +touch_icon = diff --git a/docs/conf.py b/docs/conf.py index ed7e1480..a769e40c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,5 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + # # Progress Bar documentation build configuration file, created by # sphinx-quickstart on Tue Aug 20 11:47:33 2013. @@ -10,17 +11,16 @@ # # All configuration values have a default; values that are commented out # serve to show the default. - +import datetime import os import sys -import datetime # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) -import progressbar +from progressbar import __about__ as metadata # -- General configuration ----------------------------------------------- @@ -35,9 +35,17 @@ 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', + 'sphinx.ext.mathjax', + 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', ] +suppress_warnings = [ + 'image.nonlocal_uri', +] + +needs_sphinx = '1.4' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -51,21 +59,18 @@ master_doc = 'index' # General information about the project. -project = u'Progress Bar' -project_slug = ''.join(project.capitalize().split()) -copyright = u'%s, %s' % ( - datetime.date.today().year, - progressbar.__author__, -) +project = 'Progress Bar' +project_slug: str = ''.join(project.capitalize().split()) +copyright = f'{datetime.date.today().year}, {metadata.__author__}' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = progressbar.__version__ +version: str = metadata.__version__ # The full version, including alpha/beta/rc tags. -release = progressbar.__version__ +release: str = metadata.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -93,7 +98,7 @@ # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -# show_authors = False +show_authors = True # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' @@ -182,7 +187,7 @@ # html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = '%sdoc' % project_slug +htmlhelp_basename = f'{project_slug}doc' # -- Options for LaTeX output -------------------------------------------- @@ -190,19 +195,22 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ('index', '%s.tex' % project_slug, u'%s Documentation' % project, - progressbar.__author__, 'manual'), +latex_documents: list[tuple[str, ...]] = [ + ( + 'index', + f'{project_slug}.tex', + f'{project} Documentation', + metadata.__author__, + 'manual', + ) ] # The name of an image file (relative to this directory) to place at the top of @@ -230,9 +238,14 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', project_slug.lower(), u'%s Documentation' % project, - [progressbar.__author__], 1) +man_pages: list[tuple[str, str, str, list[str], int]] = [ + ( + 'index', + project_slug.lower(), + f'{project} Documentation', + [metadata.__author__], + 1, + ) ] # If true, show URL addresses after external links. @@ -244,10 +257,16 @@ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) -texinfo_documents = [ - ('index', project_slug, u'%s Documentation' % project, - progressbar.__author__, project_slug, 'One line description of project.', - 'Miscellaneous'), +texinfo_documents: list[tuple[str, ...]] = [ + ( + 'index', + project_slug, + f'{project} Documentation', + metadata.__author__, + project_slug, + 'One line description of project.', + 'Miscellaneous', + ) ] # Documents to append as an appendix to all manuals. @@ -266,10 +285,10 @@ # -- Options for Epub output --------------------------------------------- # Bibliographic Dublin Core info. -epub_title = project -epub_author = progressbar.__author__ -epub_publisher = progressbar.__author__ -epub_copyright = copyright +epub_title: str = project +epub_author: str = metadata.__author__ +epub_publisher: str = metadata.__author__ +epub_copyright: str = copyright # The language of the text. It defaults to the language option # or en if the language is not set. @@ -295,7 +314,7 @@ # The format is a list of tuples containing the path and title. # epub_pre_files = [] -# HTML files shat should be inserted after the pages created by sphinx. +# HTML files that should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. # epub_post_files = [] @@ -322,4 +341,6 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/': None} +intersphinx_mapping: dict[str, tuple[str, None]] = { + 'python': ('https://docs.python.org/3', None) +} diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 00000000..e582053e --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1 @@ +.. include:: ../CONTRIBUTING.rst diff --git a/docs/history.rst b/docs/history.rst new file mode 100644 index 00000000..91f04cb8 --- /dev/null +++ b/docs/history.rst @@ -0,0 +1,8 @@ +.. _history: + +======= +History +======= + +.. include:: ../CHANGES.rst + :start-line: 5 diff --git a/docs/index.rst b/docs/index.rst index ea9e308a..58b16d58 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,19 +1,26 @@ +======================================== Welcome to Progress Bar's documentation! ======================================== -.. include:: ../README.rst - -Contents ------------------------------------------------------------------------------- - .. toctree:: - :maxdepth: 2 + :maxdepth: 4 + usage examples - progressbar + contributing + installation + progressbar.shortcuts + progressbar.bar + progressbar.base + progressbar.utils + progressbar.widgets + history + +.. include:: ../README.rst +****************** Indices and tables -================== +****************** * :ref:`genindex` * :ref:`modindex` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 00000000..fefb40bd --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,16 @@ +============ +Installation +============ + +At the command line:: + + $ pip install progressbar2 + +Or if you don't have pip:: + + $ easy_install progressbar2 + +Or, if you have virtualenvwrapper installed:: + + $ mkvirtualenv progressbar2 + $ pip install progressbar2 diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..36d092c9 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,242 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\progressbar.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\progressbar.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/progressbar.algorithms.rst b/docs/progressbar.algorithms.rst new file mode 100644 index 00000000..bf239d71 --- /dev/null +++ b/docs/progressbar.algorithms.rst @@ -0,0 +1,7 @@ +progressbar.algorithms module +============================= + +.. automodule:: progressbar.algorithms + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.bar.rst b/docs/progressbar.bar.rst new file mode 100644 index 00000000..7b7a0a39 --- /dev/null +++ b/docs/progressbar.bar.rst @@ -0,0 +1,8 @@ +progressbar.bar module +====================== + +.. automodule:: progressbar.bar + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/progressbar.base.rst b/docs/progressbar.base.rst new file mode 100644 index 00000000..6b9265ba --- /dev/null +++ b/docs/progressbar.base.rst @@ -0,0 +1,7 @@ +progressbar.base module +======================= + +.. automodule:: progressbar.base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.env.rst b/docs/progressbar.env.rst new file mode 100644 index 00000000..a818e0b1 --- /dev/null +++ b/docs/progressbar.env.rst @@ -0,0 +1,7 @@ +progressbar.env module +====================== + +.. automodule:: progressbar.env + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.multi.rst b/docs/progressbar.multi.rst new file mode 100644 index 00000000..5d8b85fd --- /dev/null +++ b/docs/progressbar.multi.rst @@ -0,0 +1,7 @@ +progressbar.multi module +======================== + +.. automodule:: progressbar.multi + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.rst b/docs/progressbar.rst index a78d99d3..674f6b64 100644 --- a/docs/progressbar.rst +++ b/docs/progressbar.rst @@ -1,19 +1,31 @@ -progressbar Package +progressbar package =================== -:mod:`progressbar` Package --------------------------- +Subpackages +----------- -.. automodule:: progressbar - :members: - :undoc-members: - :show-inheritance: +.. toctree:: + :maxdepth: 4 + + progressbar.terminal + +Submodules +---------- -:mod:`widgets` Module ---------------------- +.. toctree:: + :maxdepth: 4 -.. automodule:: progressbar.widgets - :members: - :undoc-members: - :show-inheritance: + progressbar.bar + progressbar.base + progressbar.multi + progressbar.shortcuts + progressbar.utils + progressbar.widgets +Module contents +--------------- + +.. automodule:: progressbar + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.shortcuts.rst b/docs/progressbar.shortcuts.rst new file mode 100644 index 00000000..eda60479 --- /dev/null +++ b/docs/progressbar.shortcuts.rst @@ -0,0 +1,7 @@ +progressbar\.shortcuts module +============================= + +.. automodule:: progressbar.shortcuts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.base.rst b/docs/progressbar.terminal.base.rst new file mode 100644 index 00000000..8114b8cf --- /dev/null +++ b/docs/progressbar.terminal.base.rst @@ -0,0 +1,7 @@ +progressbar.terminal.base module +================================ + +.. automodule:: progressbar.terminal.base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.colors.rst b/docs/progressbar.terminal.colors.rst new file mode 100644 index 00000000..d03706f7 --- /dev/null +++ b/docs/progressbar.terminal.colors.rst @@ -0,0 +1,7 @@ +progressbar.terminal.colors module +================================== + +.. automodule:: progressbar.terminal.colors + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.rst b/docs/progressbar.terminal.rst new file mode 100644 index 00000000..dba09353 --- /dev/null +++ b/docs/progressbar.terminal.rst @@ -0,0 +1,28 @@ +progressbar.terminal package +============================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal.os_specific + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal.base + progressbar.terminal.colors + progressbar.terminal.stream + +Module contents +--------------- + +.. automodule:: progressbar.terminal + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.stream.rst b/docs/progressbar.terminal.stream.rst new file mode 100644 index 00000000..2bb3b355 --- /dev/null +++ b/docs/progressbar.terminal.stream.rst @@ -0,0 +1,7 @@ +progressbar.terminal.stream module +================================== + +.. automodule:: progressbar.terminal.stream + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.utils.rst b/docs/progressbar.utils.rst new file mode 100644 index 00000000..f73cbaa6 --- /dev/null +++ b/docs/progressbar.utils.rst @@ -0,0 +1,7 @@ +progressbar.utils module +======================== + +.. automodule:: progressbar.utils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.widgets.rst b/docs/progressbar.widgets.rst new file mode 100644 index 00000000..c41c61e8 --- /dev/null +++ b/docs/progressbar.widgets.rst @@ -0,0 +1,7 @@ +progressbar.widgets module +========================== + +.. automodule:: progressbar.widgets + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..62d6cd8a --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +-e.[docs,tests] diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 00000000..6aa03303 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,70 @@ +======== +Usage +======== + +There are many ways to use Python Progressbar, you can see a few basic examples +here but there are many more in the :doc:`examples` file. + +Wrapping an iterable +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + bar = progressbar.ProgressBar() + for i in bar(range(100)): + time.sleep(0.02) + +Context wrapper +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + 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 +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + bar = progressbar.ProgressBar(redirect_stdout=True) + for i in range(100): + print 'Some text', i + time.sleep(0.1) + bar.update(i) + +Progressbar with unknown length +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + bar = progressbar.ProgressBar(max_value=progressbar.UnknownLength) + for i in range(20): + time.sleep(0.1) + bar.update(i) + +Bar with custom widgets +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + bar = progressbar.ProgressBar(widgets=[ + ' [', progressbar.Timer(), '] ', + progressbar.Bar(), + ' (', progressbar.ETA(), ') ', + ]) + for i in bar(range(20)): + time.sleep(0.1) + diff --git a/examples.py b/examples.py index 7c60b876..aa711793 100644 --- a/examples.py +++ b/examples.py @@ -1,342 +1,866 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- - -from __future__ import print_function +from __future__ import annotations +import contextlib +import functools +import os +import random import sys import time +import typing -from progressbar import AnimatedMarker, Bar, BouncingBar, Counter, ETA, \ - FileTransferSpeed, FormatLabel, Percentage, \ - ProgressBar, ReverseBar, RotatingMarker, \ - SimpleProgress, Timer, AdaptiveETA +import progressbar -examples = [] +examples: list[typing.Callable[[typing.Any], typing.Any]] = [] def example(fn): - def wrapped(): + """Wrap the examples so they generate readable output""" + + @functools.wraps(fn) + def wrapped(*args, **kwargs): try: - sys.stdout.write('Running: %s\n' % fn.__name__) - fn() + sys.stdout.write(f'Running: {fn.__name__}\n') + fn(*args, **kwargs) sys.stdout.write('\n') except KeyboardInterrupt: sys.stdout.write('\nSkipping example.\n\n') + # Sleep a bit to make killing the script easier + time.sleep(0.2) examples.append(wrapped) return wrapped @example -def with_example0(): - with ProgressBar(maxval=10) as progress: - for i in range(10): - # do something - time.sleep(0.001) - progress.update(i) +def fast_example() -> None: + """Updates bar really quickly to cause flickering""" + with progressbar.ProgressBar(widgets=[progressbar.Bar()]) as bar: + for i in range(100): + bar.update(int(i / 10), force=True) + + +@example +def shortcut_example() -> None: + for _ in progressbar.progressbar(range(10)): + time.sleep(0.1) + + +@example +def prefixed_shortcut_example() -> None: + for _ in progressbar.progressbar(range(10), prefix='Hi: '): + time.sleep(0.1) + + +@example +def parallel_bars_multibar_example() -> None: + if os.name == 'nt': + print( + 'Skipping multibar example on Windows due to threading ' + 'incompatibilities with the example code.' + ) + return + + BARS = 5 + N = 50 + + def do_something(bar): + for _ in bar(range(N)): + # Sleep up to 0.1 seconds + time.sleep(random.random() * 0.1) + + with progressbar.MultiBar() as multibar: + bar_labels = [] + for i in range(BARS): + # Get a progressbar + bar_label = 'Bar #%d' % i + bar_labels.append(bar_label) + multibar[bar_label] + + for _ in range(N * BARS): + time.sleep(0.005) + + bar_i = random.randrange(0, BARS) + bar_label = bar_labels[bar_i] + # Increment one of the progress bars at random + multibar[bar_label].increment() + + +@example +def multiple_bars_line_offset_example() -> None: + BARS = 5 + N = 100 + + bars = [ + progressbar.ProgressBar( + max_value=N, + # We add 1 to the line offset to account for the `print_fd` + line_offset=i + 1, + max_error=False, + ) + for i in range(BARS) + ] + # Create a file descriptor for regular printing as well + print_fd = progressbar.LineOffsetStreamWrapper(lines=0, stream=sys.stdout) + assert print_fd + + # The progress bar updates, normally you would do something useful here + for _ in range(N * BARS): + time.sleep(0.005) + + # Increment one of the progress bars at random + bars[random.randrange(0, BARS)].increment() + + # Cleanup the bars + for bar in bars: + bar.finish() + # Add a newline to make sure the next print starts on a new line + print() + + +@example +def templated_shortcut_example() -> None: + for _ in progressbar.progressbar(range(10), suffix='{seconds_elapsed:.1}'): + time.sleep(0.1) + + +@example +def job_status_example() -> None: + with progressbar.ProgressBar( + redirect_stdout=True, + widgets=[progressbar.widgets.JobStatusBar('status')], + ) as bar: + for _ in range(30): + print('random', random.random()) + # Roughly 1/3 probability for each status ;) + # Yes... probability is confusing at times + if random.random() > 0.66: + bar.increment(status=True) + elif random.random() > 0.5: + bar.increment(status=False) + else: + bar.increment(status=None) + time.sleep(0.1) @example -def with_example1(): - with ProgressBar(maxval=10, redirect_stdout=True) as p: +def with_example_stdout_redirection() -> None: + with progressbar.ProgressBar(max_value=10, redirect_stdout=True) as p: for i in range(10): + if i % 3 == 0: + print('Some print statement %i' % i) # do something p.update(i) - time.sleep(0.001) + time.sleep(0.1) @example -def example0(): - pbar = ProgressBar(widgets=[Percentage(), Bar()], maxval=10).start() +def basic_widget_example() -> None: + widgets = [progressbar.Percentage(), progressbar.Bar()] + bar = progressbar.ProgressBar(widgets=widgets, max_value=10).start() for i in range(10): # do something - time.sleep(0.001) - pbar.update(i + 1) - pbar.finish() + time.sleep(0.1) + bar.update(i + 1) + bar.finish() @example -def example1(): - widgets = ['Test: ', Percentage(), ' ', Bar(marker=RotatingMarker()), - ' ', ETA(), ' ', FileTransferSpeed()] - pbar = ProgressBar(widgets=widgets, maxval=1000).start() - for i in range(100): +def color_bar_example() -> None: + widgets = [ + '\x1b[33mColorful example\x1b[39m', + progressbar.Percentage(), + progressbar.Bar(marker='\x1b[32m#\x1b[39m'), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=10).start() + for i in range(10): # do something - pbar.update(10 * i + 1) - pbar.finish() + time.sleep(0.1) + bar.update(i + 1) + bar.finish() @example -def example2(): - class CrazyFileTransferSpeed(FileTransferSpeed): +def color_bar_animated_marker_example() -> None: + widgets = [ + # Colored animated marker with colored fill: + progressbar.Bar( + marker=progressbar.AnimatedMarker( + fill='x', + # fill='█', + fill_wrap='\x1b[32m{}\x1b[39m', + marker_wrap='\x1b[31m{}\x1b[39m', + ) + ), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=10).start() + for i in range(10): + # do something + time.sleep(0.1) + bar.update(i + 1) + bar.finish() - "It's bigger between 45 and 80 percent" - def update(self, pbar): - if 45 < pbar.percentage() < 80: - return 'Bigger Now ' + FileTransferSpeed.update(self, pbar) - else: - return FileTransferSpeed.update(self, pbar) +@example +def multi_range_bar_example() -> None: + markers = [ + '\x1b[32m█\x1b[39m', # Done + '\x1b[33m#\x1b[39m', # Processing + '\x1b[31m.\x1b[39m', # Scheduling + ' ', # Not started + ] + widgets = [progressbar.MultiRangeBar('amounts', markers=markers)] + amounts = [0] * (len(markers) - 1) + [25] + + with progressbar.ProgressBar(widgets=widgets, max_value=10).start() as bar: + while True: + incomplete_items = [ + idx + for idx, amount in enumerate(amounts) + for _ in range(amount) + if idx != 0 + ] + if not incomplete_items: + break + which = random.choice(incomplete_items) + amounts[which] -= 1 + amounts[which - 1] += 1 + + bar.update(amounts=amounts, force=True) + time.sleep(0.02) + + +@example +def multi_progress_bar_example(left: bool = True) -> None: + jobs = [ + # Each job takes between 1 and 10 steps to complete + [0, random.randint(1, 10)] + for _ in range(25) # 25 jobs total + ] + + widgets = [ + progressbar.Percentage(), + ' ', + progressbar.MultiProgressBar('jobs', fill_left=left), + ] + + max_value = sum([total for progress, total in jobs]) + with progressbar.ProgressBar(widgets=widgets, max_value=max_value) as bar: + while True: + incomplete_jobs = [ + idx + for idx, (progress, total) in enumerate(jobs) + if progress < total + ] + if not incomplete_jobs: + break + which = random.choice(incomplete_jobs) + jobs[which][0] += 1 + progress = sum([progress for progress, total in jobs]) + + bar.update(progress, jobs=jobs, force=True) + time.sleep(0.02) + + +@example +def granular_progress_example() -> None: + widgets = [ + progressbar.GranularBar(markers=' ▏▎▍▌▋▊▉█', left='', right='|'), + progressbar.GranularBar(markers=' ▁▂▃▄▅▆▇█', left='', right='|'), + progressbar.GranularBar(markers=' ▖▌▛█', left='', right='|'), + progressbar.GranularBar(markers=' ░▒▓█', left='', right='|'), + progressbar.GranularBar(markers=' ⡀⡄⡆⡇⣇⣧⣷⣿', left='', right='|'), + progressbar.GranularBar(markers=' .oO', left='', right=''), + ] + for _ in progressbar.progressbar(list(range(100)), widgets=widgets): + time.sleep(0.03) + + for _ in progressbar.progressbar(iter(range(100)), widgets=widgets): + time.sleep(0.03) - widgets = [CrazyFileTransferSpeed(), ' <<<', Bar(), '>>> ', - Percentage(), ' ', ETA()] - pbar = ProgressBar(widgets=widgets, maxval=1000) + +@example +def percentage_label_bar_example() -> None: + widgets = [progressbar.PercentageLabelBar()] + bar = progressbar.ProgressBar(widgets=widgets, max_value=10).start() + for i in range(10): + # do something + time.sleep(0.1) + bar.update(i + 1) + bar.finish() + + +@example +def file_transfer_example() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=1000).start() + for i in range(100): + # do something + time.sleep(0.01) + bar.update(10 * i + 1) + bar.finish() + + +@example +def custom_file_transfer_example() -> None: + class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): + """ + It's bigger between 45 and 80 percent + """ + + def update(self, bar): + if 45 < bar.percentage() < 80: + return 'Bigger Now ' + progressbar.FileTransferSpeed.update( + self, bar + ) + else: + return progressbar.FileTransferSpeed.update(self, bar) + + widgets = [ + CrazyFileTransferSpeed(), + ' <<<', + progressbar.Bar(), + '>>> ', + progressbar.Percentage(), + ' ', + progressbar.ETA(), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=1000) # maybe do something - pbar.start() + bar.start() for i in range(200): # do something - pbar.update(5 * i + 1) - pbar.finish() + time.sleep(0.01) + bar.update(5 * i + 1) + bar.finish() @example -def example3(): - widgets = [Bar('>'), ' ', ETA(), ' ', ReverseBar('<')] - pbar = ProgressBar(widgets=widgets, maxval=1000).start() +def double_bar_example() -> None: + widgets = [ + progressbar.Bar('>'), + ' ', + progressbar.ETA(), + ' ', + progressbar.ReverseBar('<'), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=1000).start() for i in range(100): # do something - pbar.update(10 * i + 1) - pbar.finish() + time.sleep(0.01) + bar.update(10 * i + 1) + bar.finish() @example -def example4(): - widgets = ['Test: ', Percentage(), ' ', - Bar(marker='0', left='[', right=']'), - ' ', ETA(), ' ', FileTransferSpeed()] - pbar = ProgressBar(widgets=widgets, maxval=500) - pbar.start() - for i in range(100, 500 + 1, 50): - time.sleep(0.001) - pbar.update(i) - pbar.finish() +def basic_file_transfer() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker='0', left='[', right=']'), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=500) + bar.start() + # Go beyond the max_value + for i in range(100, 501, 50): + time.sleep(0.1) + bar.update(i) + bar.finish() @example -def example5(): - pbar = ProgressBar(widgets=[SimpleProgress()], maxval=17).start() +def simple_progress() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.SimpleProgress()], + max_value=17, + ).start() for i in range(17): - time.sleep(0.001) - pbar.update(i + 1) - pbar.finish() + time.sleep(0.1) + bar.update(i + 1) + bar.finish() @example -def example6(): - pbar = ProgressBar().start() +def basic_progress() -> None: + bar = progressbar.ProgressBar().start() for i in range(10): - time.sleep(0.001) - pbar.update(i + 1) - pbar.finish() + time.sleep(0.1) + bar.update(i + 1) + bar.finish() + + +@example +def progress_with_automatic_max() -> None: + # Progressbar can guess max_value automatically. + bar = progressbar.ProgressBar() + for _ in bar(range(8)): + time.sleep(0.1) @example -def example7(): - pbar = ProgressBar() # Progressbar can guess maxval automatically. - for i in pbar(range(8)): - time.sleep(0.001) +def progress_with_unavailable_max() -> None: + # Progressbar can't guess max_value. + bar = progressbar.ProgressBar(max_value=8) + for _ in bar(i for i in range(8)): + time.sleep(0.1) @example -def example8(): - pbar = ProgressBar(maxval=8) # Progressbar can't guess maxval. - for i in pbar((i for i in range(8))): - time.sleep(0.001) +def animated_marker() -> None: + bar = progressbar.ProgressBar( + widgets=['Working: ', progressbar.AnimatedMarker()] + ) + for _ in bar(i for i in range(5)): + time.sleep(0.1) @example -def example9(): - pbar = ProgressBar(widgets=['Working: ', AnimatedMarker()]) - for i in pbar((i for i in range(5))): - time.sleep(0.001) +def filling_bar_animated_marker() -> None: + bar = progressbar.ProgressBar( + widgets=[ + progressbar.Bar( + marker=progressbar.AnimatedMarker(fill='#'), + ), + ] + ) + for _ in bar(range(15)): + time.sleep(0.1) @example -def example10(): - widgets = ['Processed: ', Counter(), ' lines (', Timer(), ')'] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(15))): - time.sleep(0.001) +def counter_and_timer() -> None: + widgets = [ + 'Processed: ', + progressbar.Counter('Counter: %(value)05d'), + ' lines (', + progressbar.Timer(), + ')', + ] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(15)): + time.sleep(0.1) @example -def example11(): - widgets = [FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(15))): - time.sleep(0.001) +def format_label() -> None: + widgets = [ + progressbar.FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)') + ] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(15)): + time.sleep(0.1) @example -def example12(): - widgets = ['Balloon: ', AnimatedMarker(markers='.oO@* ')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) +def animated_balloons() -> None: + widgets = ['Balloon: ', progressbar.AnimatedMarker(markers='.oO@* ')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) @example -def example13(): +def animated_arrows() -> None: # You may need python 3.x to see this correctly try: - widgets = ['Arrows: ', AnimatedMarker(markers='←↖↑↗→↘↓↙')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) + widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='←↖↑↗→↘↓↙')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def example14(): +def animated_filled_arrows() -> None: # You may need python 3.x to see this correctly try: - widgets = ['Arrows: ', AnimatedMarker(markers='◢◣◤◥')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) + widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='◢◣◤◥')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def example15(): +def animated_wheels() -> None: # You may need python 3.x to see this correctly try: - widgets = ['Wheels: ', AnimatedMarker(markers='◐◓◑◒')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) + widgets = ['Wheels: ', progressbar.AnimatedMarker(markers='◐◓◑◒')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def example16(): - widgets = [FormatLabel('Bouncer: value %(value)d - '), BouncingBar()] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(18))): - time.sleep(0.001) +def format_label_bouncer() -> None: + widgets = [ + progressbar.FormatLabel('Bouncer: value %(value)d - '), + progressbar.BouncingBar(), + ] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(100)): + time.sleep(0.01) @example -def example17(): - widgets = [FormatLabel('Animated Bouncer: value %(value)d - '), - BouncingBar(marker=RotatingMarker())] +def format_label_rotating_bouncer() -> None: + widgets = [ + progressbar.FormatLabel('Animated Bouncer: value %(value)d - '), + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(18))): - time.sleep(0.001) + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(18)): + time.sleep(0.1) @example -def with_example18(): - with ProgressBar(maxval=10, term_width=20, left_justify=False) as progress: - assert progress._env_size() is not None +def with_right_justify() -> None: + with progressbar.ProgressBar( + max_value=10, term_width=20, left_justify=False + ) as progress: + assert progress.term_width is not None for i in range(10): progress.update(i) @example -def with_example19(): - with ProgressBar(maxval=1) as progress: - try: - progress.update(2) - except ValueError: - pass +def exceeding_maximum() -> None: + with progressbar.ProgressBar(max_value=1) as progress, contextlib.suppress( + ValueError + ): + progress.update(2) @example -def with_example20(): - progress = ProgressBar(maxval=1) - try: +def reaching_maximum() -> None: + progress = progressbar.ProgressBar(max_value=1) + with contextlib.suppress(RuntimeError): progress.update(1) - except RuntimeError: - pass @example -def with_example21a(): - with ProgressBar(maxval=1, redirect_stdout=True) as progress: +def stdout_redirection() -> None: + with progressbar.ProgressBar(redirect_stdout=True) as progress: print('', file=sys.stdout) progress.update(0) @example -def with_example21b(): - with ProgressBar(maxval=1, redirect_stderr=True) as progress: +def stderr_redirection() -> None: + with progressbar.ProgressBar(redirect_stderr=True) as progress: print('', file=sys.stderr) progress.update(0) @example -def with_example22(): - try: - with ProgressBar(maxval=-1) as progress: - progress.start() - except ValueError: - pass - - -@example -def example23(): - widgets = [BouncingBar(marker=RotatingMarker())] - with ProgressBar(widgets=widgets, maxval=20, term_width=10) as progress: +def rotating_bouncing_marker() -> None: + widgets = [progressbar.BouncingBar(marker=progressbar.RotatingMarker())] + with progressbar.ProgressBar( + widgets=widgets, max_value=20, term_width=10 + ) as progress: for i in range(20): + time.sleep(0.1) progress.update(i) - widgets = [BouncingBar(marker=RotatingMarker(), fill_left=False)] - with ProgressBar(widgets=widgets, maxval=20, term_width=10) as progress: + widgets = [ + progressbar.BouncingBar( + marker=progressbar.RotatingMarker(), fill_left=False + ) + ] + with progressbar.ProgressBar( + widgets=widgets, max_value=20, term_width=10 + ) as progress: for i in range(20): + time.sleep(0.1) progress.update(i) @example -def example24(): - pbar = ProgressBar(widgets=[Percentage(), Bar()], maxval=10).start() - for i in range(10): +def incrementing_bar() -> None: + bar = progressbar.ProgressBar( + widgets=[ + progressbar.Percentage(), + progressbar.Bar(), + ], + max_value=10, + ).start() + for _ in range(10): # do something - time.sleep(0.001) - pbar += 1 - pbar.finish() + time.sleep(0.1) + bar += 1 + bar.finish() @example -def example25(): - widgets = ['Test: ', Percentage(), ' ', Bar(marker=RotatingMarker()), - ' ', ETA(), ' ', FileTransferSpeed()] - pbar = ProgressBar(widgets=widgets, maxval=1000, redirect_stdout=True).start() - for i in range(100): +def increment_bar_with_output_redirection() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + bar = progressbar.ProgressBar( + widgets=widgets, max_value=100, redirect_stdout=True + ).start() + for i in range(10): # do something - pbar += 10 - pbar.finish() + time.sleep(0.01) + bar += 10 + print('Got', i) + bar.finish() + @example -def example26(): - widgets = [Percentage(), - ' ', Bar(), - ' ', ETA(), - ' ', AdaptiveETA()] - pbar = ProgressBar(widgets=widgets, maxval=500) - pbar.start() +def eta_types_demonstration() -> None: + widgets = [ + progressbar.Percentage(), + ' ETA: ', + progressbar.ETA(), + ' Adaptive : ', + progressbar.AdaptiveETA(), + ' Smoothing(a=0.1): ', + progressbar.SmoothingETA(smoothing_parameters=dict(alpha=0.1)), + ' Smoothing(a=0.9): ', + progressbar.SmoothingETA(smoothing_parameters=dict(alpha=0.9)), + ' Absolute: ', + progressbar.AbsoluteETA(), + ' Transfer: ', + progressbar.FileTransferSpeed(), + ' Adaptive T: ', + progressbar.AdaptiveTransferSpeed(), + ' ', + progressbar.Bar(), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=500) + bar.start() for i in range(500): - time.sleep(0.001 + (i < 100) * 0.0001 + (i > 400) * 0.009) - pbar.update(i + 1) - pbar.finish() + if i < 100: + time.sleep(0.02) + elif i > 400: + time.sleep(0.1) + else: + time.sleep(0.01) + bar.update(i + 1) + bar.finish() + @example -def example27(): - # Testing AdaptiveETA when the value doesn't actually change - pbar = ProgressBar(widgets=[AdaptiveETA()], maxval=2, poll=0.0001) - pbar.start() - pbar.update(1) - time.sleep(0.001) - pbar.update(1) - pbar.finish() +def adaptive_eta_without_value_change() -> None: + # Testing progressbar.AdaptiveETA when the value doesn't actually change + bar = progressbar.ProgressBar( + widgets=[ + progressbar.AdaptiveETA(), + progressbar.AdaptiveTransferSpeed(), + ], + max_value=2, + poll_interval=0.0001, + ) + bar.start() + for _ in range(100): + bar.update(1) + time.sleep(0.1) + bar.finish() -if __name__ == '__main__': - try: + +@example +def iterator_with_max_value() -> None: + # Testing using progressbar as an iterator with a max value + bar = progressbar.ProgressBar() + + for _ in bar(iter(range(100)), 100): + # iter range is a way to get an iterator in both python 2 and 3 + time.sleep(0.01) + + +@example +def eta() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' | ETA: ', + progressbar.ETA(), + ' | AbsoluteETA: ', + progressbar.AbsoluteETA(), + ' | AdaptiveETA: ', + progressbar.AdaptiveETA(), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=50).start() + for i in range(50): + time.sleep(0.1) + bar.update(i + 1) + bar.finish() + + +@example +def variables() -> None: + # Use progressbar.Variable to keep track of some parameter(s) during + # your calculations + widgets = [ + progressbar.Percentage(), + progressbar.Bar(), + progressbar.Variable('loss'), + ', ', + progressbar.Variable('username', width=12, precision=12), + ] + with progressbar.ProgressBar(max_value=100, widgets=widgets) as bar: + min_so_far = 1 + for i in range(100): + time.sleep(0.01) + val = random.random() + if val < min_so_far: + min_so_far = val + bar.update(i, loss=min_so_far, username='Some user') + + +@example +def user_variables() -> None: + tasks = { + 'Download': [ + 'SDK', + 'IDE', + 'Dependencies', + ], + 'Build': [ + 'Compile', + 'Link', + ], + 'Test': [ + 'Unit tests', + 'Integration tests', + 'Regression tests', + ], + 'Deploy': [ + 'Send to server', + 'Restart server', + ], + } + num_subtasks = sum(len(x) for x in tasks.values()) + + with progressbar.ProgressBar( + prefix='{variables.task} >> {variables.subtask}', + variables={'task': '--', 'subtask': '--'}, + max_value=10 * num_subtasks, + ) as bar: + for tasks_name, subtasks in tasks.items(): + for subtask_name in subtasks: + for _ in range(10): + bar.update( + bar.value + 1, task=tasks_name, subtask=subtask_name + ) + time.sleep(0.1) + + +@example +def format_custom_text() -> None: + format_custom_text = progressbar.FormatCustomText( + 'Spam: %(spam).1f kg, eggs: %(eggs)d', + dict( + spam=0.25, + eggs=3, + ), + ) + + bar = progressbar.ProgressBar( + widgets=[ + format_custom_text, + ' :: ', + progressbar.Percentage(), + ] + ) + for i in bar(range(25)): + format_custom_text.update_mapping(eggs=i * 2) + time.sleep(0.1) + + +@example +def simple_api_example() -> None: + bar = progressbar.ProgressBar(widget_kwargs=dict(fill='█')) + for _ in bar(range(200)): + time.sleep(0.02) + + +@example +def eta_on_generators(): + def gen(): + for _ in range(200): + yield None + + widgets = [ + progressbar.AdaptiveETA(), + ' ', + progressbar.ETA(), + ' ', + progressbar.Timer(), + ] + + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(gen()): + time.sleep(0.02) + + +@example +def percentage_on_generators(): + def gen(): + for _ in range(200): + yield None + + widgets = [ + progressbar.Counter(), + ' ', + progressbar.Percentage(), + ' ', + progressbar.SimpleProgress(), + ' ', + ] + + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(gen()): + time.sleep(0.02) + + +def test(*tests) -> None: + if tests: + no_tests = True + for example in examples: + for test in tests: + if test in example.__name__: + example() + no_tests = False + break + + if no_tests: + for example in examples: + print('Skipping', example.__name__) + else: for example in examples: example() + + +if __name__ == '__main__': + try: + test(*sys.argv[1:]) except KeyboardInterrupt: - sys.stdout('\nQuitting examples.\n') + sys.stdout.write('\nQuitting examples.\n') diff --git a/progressbar/__about__.py b/progressbar/__about__.py new file mode 100644 index 00000000..785fff86 --- /dev/null +++ b/progressbar/__about__.py @@ -0,0 +1,27 @@ +"""Text progress bar library for Python. + +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 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. + +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. +""" + +__title__ = 'Python Progressbar' +__package_name__ = 'progressbar2' +__author__ = 'Rick van Hattem (Wolph)' +__description__: str = ' '.join( + """ +A Python Progressbar library to provide visual (yet text based) progress to +long running operations. +""".strip().split(), +) +__email__ = 'wolph@wol.ph' +__version__ = '4.5.0' +__license__ = 'BSD' +__copyright__ = 'Copyright 2015 Rick van Hattem (Wolph)' +__url__ = 'https://github.com/WoLpH/python-progressbar' diff --git a/progressbar/__init__.py b/progressbar/__init__.py index 90e44f2b..ff76ff45 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -1,368 +1,91 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# -# progressbar - Text progress bar library for Python. -# Copyright (c) 2005 Nilton Volpato -# Copyright (c) 2012 Rick van Hattem -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - - -'''Text progress bar library for Python. - -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 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 three types -of widgets: - - - a string, which always shows itself - - - a ProgressBarWidget, which may return a different value every time its - update method is called - - - a ProgressBarWidgetHFill, which is like ProgressBarWidget, except it - expands to fill the remaining width of the line. - -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. -''' - -from __future__ import division, absolute_import, with_statement - -import math -import os -import signal -import sys -import time from datetime import date -try: - from fcntl import ioctl - from array import array - import termios -except ImportError: # pragma: no cover - pass +from .__about__ import __author__, __version__ +from .algorithms import ( + DoubleExponentialMovingAverage, + ExponentialMovingAverage, + SmoothingAlgorithm, +) +from .bar import DataTransferBar, NullBar, ProgressBar +from .base import UnknownLength +from .multi import MultiBar, SortKey +from .shortcuts import progressbar +from .terminal.stream import LineOffsetStreamWrapper +from .utils import len_color, streams +from .widgets import ( + ETA, + AbsoluteETA, + AdaptiveETA, + AdaptiveTransferSpeed, + AnimatedMarker, + Bar, + BouncingBar, + Counter, + CurrentTime, + DataSize, + DynamicMessage, + FileTransferSpeed, + FormatCustomText, + FormatLabel, + FormatLabelBar, + GranularBar, + JobStatusBar, + MultiProgressBar, + MultiRangeBar, + Percentage, + PercentageLabelBar, + ReverseBar, + RotatingMarker, + SimpleProgress, + SmoothingETA, + Timer, + Variable, + VariableMixin, +) -try: - from cStringIO import StringIO -except ImportError: # pragma: no cover - try: - from StringIO import StringIO - except ImportError: - from io import StringIO - -from progressbar.widgets import * - -__author__ = 'Rick van Hattem' -__author_email__ = 'Rick.van.Hattem@Fawo.nl' __date__ = str(date.today()) -__version__ = '2.6.6' - - -class UnknownLength: - pass - - -class ProgressBar(object): - - '''The ProgressBar class which updates and prints the bar. - - A common way of using it is like: - - >>> pbar = ProgressBar().start() - >>> for i in range(100): - ... pbar.update(i+1) - ... # do something - ... - >>> pbar.finish() - - You can also use a ProgressBar as an iterator: - - >>> progress = ProgressBar() - >>> some_iterable = range(100) - >>> for i in progress(some_iterable): - ... # do something - ... pass - ... - - Since the progress bar is incredibly customizable you can specify - different widgets of any type in any order. You can even write your own - widgets! However, since there are already a good number of widgets you - should probably play around with them before moving on to create your own - widgets. - - The term_width parameter represents the current terminal width. If the - parameter is set to an integer then the progress bar will use that, - otherwise it will attempt to determine the terminal width falling back to - 80 columns if the width cannot be determined. - - When implementing a widget's update method you are passed a reference to - the current progress bar. As a result, you have access to the - ProgressBar's methods and attributes. Although there is nothing preventing - you from changing the ProgressBar you should treat it as read only. - - Useful methods and attributes include (Public API): - - currval: current progress (0 <= currval <= maxval) - - maxval: maximum (and final) value - - finished: True if the bar has finished (reached 100%) - - start_time: the time when start() method of ProgressBar was called - - seconds_elapsed: seconds elapsed since start_time and last call to - update - - percentage(): progress in percent [0..100] - ''' - - _DEFAULT_MAXVAL = 100 - _DEFAULT_TERMSIZE = 80 - _DEFAULT_WIDGETS = [Percentage(), ' ', Bar()] - - def __init__(self, maxval=None, widgets=None, term_width=None, poll=0.1, - left_justify=True, fd=sys.stderr, redirect_stderr=False, - redirect_stdout=False): - '''Initializes a progress bar with sane defaults''' - - # Don't share a reference with any other progress bars - if widgets is None: - widgets = list(self._DEFAULT_WIDGETS) - - self.maxval = maxval - self.widgets = widgets - self.fd = fd - self.left_justify = left_justify - self.redirect_stderr = redirect_stderr - self.redirect_stdout = redirect_stdout - - self.signal_set = False - if term_width is not None: - self.term_width = term_width - else: - try: - self._handle_resize() - signal.signal(signal.SIGWINCH, self._handle_resize) - self.signal_set = True - except (SystemExit, KeyboardInterrupt): # pragma: no cover - raise - except: # pragma: no cover - self.term_width = self._env_size() - - self.__iterable = None - self._update_widgets() - self.currval = 0 - self.finished = False - self.last_update_time = None - self.poll = poll - self.seconds_elapsed = 0 - self.start_time = None - self.update_interval = 1 - - def __call__(self, iterable): - 'Use a ProgressBar to iterate through an iterable' - - try: - self.maxval = len(iterable) - except: - if self.maxval is None: - self.maxval = UnknownLength - - self.__iterable = iter(iterable) - return self - - def __iter__(self): - return self - - def __next__(self): - try: - value = next(self.__iterable) - if self.start_time is None: - self.start() - else: - self.update(self.currval + 1) - return value - except StopIteration: - self.finish() - raise - - def __exit__(self, exc_type, exc_value, traceback): - self.finish() - - def __enter__(self): - return self.start() - - # Create an alias so that Python 2.x won't complain about not being - # an iterator. - next = __next__ - - def __iadd__(self, value): - 'Updates the ProgressBar by adding a new value.' - self.update(self.currval + value) - return self - - def _env_size(self): - 'Tries to find the term_width from the environment.' - - return int(os.environ.get('COLUMNS', self._DEFAULT_TERMSIZE)) - 1 - - def _handle_resize(self, signum=None, frame=None): - 'Tries to catch resize signals sent from the terminal.' - - h, w = array('h', ioctl(self.fd, termios.TIOCGWINSZ, '\0' * 8))[:2] - self.term_width = w - - def percentage(self): - 'Returns the progress as a percentage.' - return self.currval * 100.0 / self.maxval - - percent = property(percentage) - - def _format_widgets(self): - result = [] - expanding = [] - width = self.term_width - - for index, widget in enumerate(self.widgets): - if isinstance(widget, WidgetHFill): - result.append(widget) - expanding.insert(0, index) - else: - widget = format_updatable(widget, self) - result.append(widget) - width -= len(widget) - - count = len(expanding) - while count: - portion = max(int(math.ceil(width * 1. / count)), 0) - index = expanding.pop() - count -= 1 - - widget = result[index].update(self, portion) - width -= len(widget) - result[index] = widget - - return result - - def _format_line(self): - 'Joins the widgets and justifies the line' - - widgets = ''.join(self._format_widgets()) - - if self.left_justify: - return widgets.ljust(self.term_width) - else: - return widgets.rjust(self.term_width) - - def _need_update(self): - 'Returns whether the ProgressBar should redraw the line.' - if self.currval >= self.next_update or self.finished: - return True - - delta = time.time() - self.last_update_time - return self._time_sensitive and delta > self.poll - - def _update_widgets(self): - 'Checks all widgets for the time sensitive bit' - - self._time_sensitive = any(getattr(w, 'TIME_SENSITIVE', False) - for w in self.widgets) - - def update(self, value=None): - 'Updates the ProgressBar to a new value.' - - if value is not None and value is not UnknownLength: - if (self.maxval is not UnknownLength - and not 0 <= value <= self.maxval - and not value < self.currval): - - raise ValueError('Value out of range') - - self.currval = value - - if self.start_time is None: - raise RuntimeError('You must call "start" before calling "update"') - if not self._need_update(): - return - - if self.redirect_stderr and sys.stderr.tell(): - self.fd.write('\r' + ' ' * self.term_width + '\r') - self._stderr.write(sys.stderr.getvalue()) - self._stderr.flush() - sys.stderr = StringIO() - - if self.redirect_stdout and sys.stdout.tell(): - self.fd.write('\r' + ' ' * self.term_width + '\r') - self._stdout.write(sys.stdout.getvalue()) - self._stdout.flush() - sys.stdout = StringIO() - - now = time.time() - self.seconds_elapsed = now - self.start_time - self.next_update = self.currval + self.update_interval - self.fd.write('\r' + self._format_line()) - self.last_update_time = now - - def start(self): - '''Starts measuring time, and prints the bar at 0%. - - It returns self so you can use it like this: - - >>> pbar = ProgressBar().start() - >>> for i in range(100): - ... # do something - ... pbar.update(i+1) - ... - >>> pbar.finish() - ''' - - if self.redirect_stderr: - self._stderr = sys.stderr - sys.stderr = StringIO() - - if self.redirect_stdout: - self._stdout = sys.stdout - sys.stdout = StringIO() - - if self.maxval is None: - self.maxval = self._DEFAULT_MAXVAL - - self.num_intervals = max(100, self.term_width) - self.next_update = 0 - - if self.maxval is not UnknownLength: - if self.maxval < 0: - raise ValueError('Value out of range') - self.update_interval = self.maxval / self.num_intervals - - self.start_time = self.last_update_time = time.time() - self.update(0) - - return self - - def finish(self): - 'Puts the ProgressBar bar in the finished state.' - - self.finished = True - self.update(self.maxval) - self.fd.write('\n') - if self.signal_set: - signal.signal(signal.SIGWINCH, signal.SIG_DFL) - - if self.redirect_stderr: - self._stderr.write(sys.stderr.getvalue()) - sys.stderr = self._stderr - - if self.redirect_stdout: - self._stdout.write(sys.stdout.getvalue()) - sys.stdout = self._stdout +__all__ = [ + 'progressbar', + 'len_color', + 'streams', + 'Timer', + 'ETA', + 'AdaptiveETA', + 'AbsoluteETA', + 'SmoothingETA', + 'SmoothingAlgorithm', + 'ExponentialMovingAverage', + 'DoubleExponentialMovingAverage', + 'DataSize', + 'FileTransferSpeed', + 'AdaptiveTransferSpeed', + 'AnimatedMarker', + 'Counter', + 'Percentage', + 'FormatLabel', + 'SimpleProgress', + 'Bar', + 'ReverseBar', + 'BouncingBar', + 'UnknownLength', + 'ProgressBar', + 'DataTransferBar', + 'RotatingMarker', + 'VariableMixin', + 'MultiRangeBar', + 'MultiProgressBar', + 'GranularBar', + 'FormatLabelBar', + 'PercentageLabelBar', + 'Variable', + 'DynamicMessage', + 'FormatCustomText', + 'CurrentTime', + 'NullBar', + '__author__', + '__version__', + 'LineOffsetStreamWrapper', + 'MultiBar', + 'SortKey', + 'JobStatusBar', +] diff --git a/progressbar/__main__.py b/progressbar/__main__.py new file mode 100644 index 00000000..0bfd7fb5 --- /dev/null +++ b/progressbar/__main__.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +import argparse +import contextlib +import pathlib +import sys +import typing +from pathlib import Path +from typing import IO, BinaryIO, TextIO + +import progressbar + + +def size_to_bytes(size_str: str) -> int: + """ + Convert a size string with suffixes 'k', 'm', etc., to bytes. + + Note: This function also supports '@' as a prefix to a file path to get the + file size. + + >>> size_to_bytes('1024k') + 1048576 + >>> size_to_bytes('1024m') + 1073741824 + >>> size_to_bytes('1024g') + 1099511627776 + >>> size_to_bytes('1024') + 1024 + >>> size_to_bytes('1024p') + 1125899906842624 + """ + + # Define conversion rates + suffix_exponent = { + 'k': 1, + 'm': 2, + 'g': 3, + 't': 4, + 'p': 5, + } + + # Initialize the default exponent to 0 (for bytes) + exponent = 0 + + # Check if the size starts with '@' (for file sizes, not handled here) + if size_str.startswith('@'): + return pathlib.Path(size_str[1:]).stat().st_size + + # Check if the last character is a known suffix and adjust the multiplier + if size_str[-1].lower() in suffix_exponent: + # Update exponent based on the suffix + exponent = suffix_exponent[size_str[-1].lower()] + # Remove the suffix from the size_str + size_str = size_str[:-1] + + # Convert the size_str to an integer and apply the exponent + return int(size_str) * (1024**exponent) + + +def create_argument_parser() -> argparse.ArgumentParser: + """ + Create the argument parser for the `progressbar` command. + """ + + parser = argparse.ArgumentParser( + description=""" + Monitor the progress of data through a pipe. + + Note that this is a Python implementation of the original `pv` command + that is functional but not yet feature complete. + """ + ) + + # Display switches + parser.add_argument( + '-p', + '--progress', + action='store_true', + help='Turn the progress bar on.', + ) + parser.add_argument( + '-t', '--timer', action='store_true', help='Turn the timer on.' + ) + parser.add_argument( + '-e', '--eta', action='store_true', help='Turn the ETA timer on.' + ) + parser.add_argument( + '-I', + '--fineta', + action='store_true', + help='Display the ETA as local time of arrival.', + ) + parser.add_argument( + '-r', '--rate', action='store_true', help='Turn the rate counter on.' + ) + parser.add_argument( + '-a', + '--average-rate', + action='store_true', + help='Turn the average rate counter on.', + ) + parser.add_argument( + '-b', + '--bytes', + action='store_true', + help='Turn the total byte counter on.', + ) + parser.add_argument( + '-8', + '--bits', + action='store_true', + help='Display total bits instead of bytes.', + ) + parser.add_argument( + '-T', + '--buffer-percent', + action='store_true', + help='Turn on the transfer buffer percentage display.', + ) + parser.add_argument( + '-A', + '--last-written', + type=int, + help='Show the last NUM bytes written.', + ) + parser.add_argument( + '-F', + '--format', + type=str, + help='Use the format string FORMAT for output format.', + ) + parser.add_argument( + '-n', '--numeric', action='store_true', help='Numeric output.' + ) + parser.add_argument( + '-q', + '--quiet', + action='store_true', + help='No output.', + ) + + # Output modifiers + parser.add_argument( + '-W', + '--wait', + action='store_true', + help='Wait until the first byte has been transferred.', + ) + parser.add_argument('-D', '--delay-start', type=float, help='Delay start.') + parser.add_argument( + '-s', '--size', type=str, help='Assume total data size is SIZE.' + ) + parser.add_argument( + '-l', + '--line-mode', + action='store_true', + help='Count lines instead of bytes.', + ) + parser.add_argument( + '-0', + '--null', + action='store_true', + help='Count lines terminated with a zero byte.', + ) + parser.add_argument( + '-i', '--interval', type=float, help='Interval between updates.' + ) + parser.add_argument( + '-m', + '--average-rate-window', + type=int, + help='Window for average rate calculation.', + ) + parser.add_argument( + '-w', + '--width', + type=int, + help='Assume terminal is WIDTH characters wide.', + ) + parser.add_argument( + '-H', '--height', type=int, help='Assume terminal is HEIGHT rows high.' + ) + parser.add_argument( + '-N', '--name', type=str, help='Prefix output information with NAME.' + ) + parser.add_argument( + '-f', '--force', action='store_true', help='Force output.' + ) + parser.add_argument( + '-c', + '--cursor', + action='store_true', + help='Use cursor positioning escape sequences.', + ) + + # Data transfer modifiers + parser.add_argument( + '-L', + '--rate-limit', + type=str, + help='Limit transfer to RATE bytes per second.', + ) + parser.add_argument( + '-B', + '--buffer-size', + type=str, + help='Use transfer buffer size of BYTES.', + ) + parser.add_argument( + '-C', '--no-splice', action='store_true', help='Never use splice.' + ) + parser.add_argument( + '-E', '--skip-errors', action='store_true', help='Ignore read errors.' + ) + parser.add_argument( + '-Z', + '--error-skip-block', + type=str, + help='Skip block size when ignoring errors.', + ) + parser.add_argument( + '-S', + '--stop-at-size', + action='store_true', + help='Stop transferring after SIZE bytes.', + ) + parser.add_argument( + '-Y', + '--sync', + action='store_true', + help='Synchronise buffer caches to disk after writes.', + ) + parser.add_argument( + '-K', + '--direct-io', + action='store_true', + help='Set O_DIRECT flag on all inputs/outputs.', + ) + parser.add_argument( + '-X', + '--discard', + action='store_true', + help='Discard input data instead of transferring it.', + ) + parser.add_argument( + '-d', '--watchfd', type=str, help='Watch file descriptor of process.' + ) + parser.add_argument( + '-R', + '--remote', + type=int, + help='Remote control another running instance of pv.', + ) + + # General options + parser.add_argument( + '-P', '--pidfile', type=pathlib.Path, help='Save process ID in FILE.' + ) + parser.add_argument( + 'input', + help='Input file path. Uses stdin if not specified.', + default='-', + nargs='*', + ) + parser.add_argument( + '-o', + '--output', + default='-', + help='Output file path. Uses stdout if not specified.', + ) + + return parser + + +def main(argv: list[str] | None = None) -> None: # noqa: C901 + """ + Main function for the `progressbar` command. + + Args: + argv (list[str] | None): Command-line arguments passed to the script. + + Returns: + None + """ + parser: argparse.ArgumentParser = create_argument_parser() + args: argparse.Namespace = parser.parse_args(argv) + + with contextlib.ExitStack() as stack: + output_stream: typing.IO[typing.Any] = _get_output_stream( + args.output, args.line_mode, stack + ) + + input_paths: list[BinaryIO | TextIO | Path | IO[typing.Any]] = [] + total_size: int = 0 + filesize_available: bool = True + for filename in args.input: + input_path: typing.IO[typing.Any] | pathlib.Path + if filename == '-': + if args.line_mode: + input_path = sys.stdin + else: + input_path = sys.stdin.buffer + + filesize_available = False + else: + input_path = pathlib.Path(filename) + if not input_path.exists(): + parser.error(f'File not found: {filename}') + + if not args.size: + total_size += input_path.stat().st_size + + input_paths.append(input_path) + + # Determine the size for the progress bar (if provided) + if args.size: + total_size = size_to_bytes(args.size) + filesize_available = True + + if filesize_available: + # Create the progress bar components + widgets = [ + progressbar.Percentage(), + ' ', + progressbar.Bar(), + ' ', + progressbar.Timer(), + ' ', + progressbar.FileTransferSpeed(), + ] + else: + widgets = [ + progressbar.SimpleProgress(), + ' ', + progressbar.DataSize(), + ' ', + progressbar.Timer(), + ] + + if args.eta: + widgets.append(' ') + widgets.append(progressbar.AdaptiveETA()) + + # Initialize the progress bar + bar = progressbar.ProgressBar( + # widgets=widgets, + max_value=total_size or None, + max_error=False, + ) + + # Data processing and updating the progress bar + buffer_size = ( + size_to_bytes(args.buffer_size) if args.buffer_size else 1024 + ) + total_transferred = 0 + + bar.start() + with contextlib.suppress(KeyboardInterrupt): + for input_path in input_paths: + if isinstance(input_path, pathlib.Path): + input_stream = stack.enter_context( + input_path.open('r' if args.line_mode else 'rb') + ) + else: + input_stream = input_path + + while True: + data: str | bytes + if args.line_mode: + data = input_stream.readline(buffer_size) + else: + data = input_stream.read(buffer_size) + + if not data: + break + + output_stream.write(data) + total_transferred += len(data) + bar.update(total_transferred) + + bar.finish(dirty=True) + + +def _get_output_stream( + output: str | None, + line_mode: bool, + stack: contextlib.ExitStack, +) -> typing.IO[typing.Any]: + if output and output != '-': + mode = 'w' if line_mode else 'wb' + return stack.enter_context(open(output, mode)) # noqa: SIM115 + elif line_mode: + return sys.stdout + else: + return sys.stdout.buffer + + +if __name__ == '__main__': + main() diff --git a/progressbar/algorithms.py b/progressbar/algorithms.py new file mode 100644 index 00000000..cf0faf24 --- /dev/null +++ b/progressbar/algorithms.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import abc +from datetime import timedelta + + +class SmoothingAlgorithm(abc.ABC): + @abc.abstractmethod + def __init__(self, **kwargs): + raise NotImplementedError + + @abc.abstractmethod + def update(self, new_value: float, elapsed: timedelta) -> float: + """Updates the algorithm with a new value and returns the smoothed + value. + """ + raise NotImplementedError + + +class ExponentialMovingAverage(SmoothingAlgorithm): + """ + The Exponential Moving Average (EMA) is an exponentially weighted moving + average that reduces the lag that's typically associated with a simple + moving average. It's more responsive to recent changes in data. + """ + + def __init__(self, alpha: float = 0.5) -> None: + self.alpha = alpha + self.value = 0 + + def update(self, new_value: float, elapsed: timedelta) -> float: + self.value = self.alpha * new_value + (1 - self.alpha) * self.value + return self.value + + +class DoubleExponentialMovingAverage(SmoothingAlgorithm): + """ + The Double Exponential Moving Average (DEMA) is essentially an EMA of an + EMA, which reduces the lag that's typically associated with a simple EMA. + It's more responsive to recent changes in data. + """ + + def __init__(self, alpha: float = 0.5) -> None: + self.alpha = alpha + self.ema1 = 0 + self.ema2 = 0 + + def update(self, new_value: float, elapsed: timedelta) -> float: + self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1 + self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2 + return 2 * self.ema1 - self.ema2 diff --git a/progressbar/bar.py b/progressbar/bar.py new file mode 100644 index 00000000..c267fa2a --- /dev/null +++ b/progressbar/bar.py @@ -0,0 +1,1147 @@ +from __future__ import annotations + +import abc +import contextlib +import itertools +import logging +import math +import os +import sys +import time +import timeit +import typing +import warnings +from copy import deepcopy +from datetime import datetime +from types import FrameType + +from python_utils import converters, types + +import progressbar.env +import progressbar.terminal +import progressbar.terminal.stream + +from . import ( + base, + utils, + widgets, + widgets as widgets_module, # Avoid name collision +) +from .terminal import os_specific + +logger = logging.getLogger(__name__) + +# float also accepts integers and longs but we don't want an explicit union +# due to type checking complexity +NumberT = float +ValueT = typing.Union[NumberT, typing.Type[base.UnknownLength], None] + +T = types.TypeVar('T') + + +class ProgressBarMixinBase(abc.ABC): + _started = False + _finished = False + _last_update_time: types.Optional[float] = None + + #: The terminal width. This should be automatically detected but will + #: fall back to 80 if auto detection is not possible. + term_width: int = 80 + #: The widgets to render, defaults to the result of `default_widget()` + widgets: types.MutableSequence[widgets_module.WidgetBase | str] + #: When going beyond the max_value, raise an error if True or silently + #: ignore otherwise + max_error: bool + #: Prefix the progressbar with the given string + prefix: types.Optional[str] + #: Suffix the progressbar with the given string + suffix: types.Optional[str] + #: Justify to the left if `True` or the right if `False` + left_justify: bool + #: The default keyword arguments for the `default_widgets` if no widgets + #: are configured + widget_kwargs: types.Dict[str, types.Any] + #: Custom length function for multibyte characters such as CJK + # mypy and pyright can't agree on what the correct one is... so we'll + # need to use a helper function :( + # custom_len: types.Callable[['ProgressBarMixinBase', str], int] + custom_len: types.Callable[[str], int] + #: The time the progress bar was started + initial_start_time: types.Optional[datetime] + #: The interval to poll for updates in seconds if there are updates + poll_interval: types.Optional[float] + #: The minimum interval to poll for updates in seconds even if there are + #: no updates + min_poll_interval: float + + #: Deprecated: The number of intervals that can fit on the screen with a + #: minimum of 100 + num_intervals: int = 0 + #: Deprecated: The `next_update` is kept for compatibility with external + #: libs: https://github.com/WoLpH/python-progressbar/issues/207 + next_update: int = 0 + + #: Current progress (min_value <= value <= max_value) + value: NumberT + #: Previous progress value + previous_value: types.Optional[NumberT] + #: The minimum/start value for the progress bar + min_value: NumberT + #: Maximum (and final) value. Beyond this value an error will be raised + #: unless the `max_error` parameter is `False`. + max_value: ValueT + #: The time the progressbar reached `max_value` or when `finish()` was + #: called. + end_time: types.Optional[datetime] + #: The time `start()` was called or iteration started. + start_time: types.Optional[datetime] + #: Seconds between `start_time` and last call to `update()` + seconds_elapsed: float + + #: Extra data for widgets with persistent state. This is used by + #: sampling widgets for example. Since widgets can be shared between + #: multiple progressbars we need to store the state with the progressbar. + extra: types.Dict[str, types.Any] + + def get_last_update_time(self) -> types.Optional[datetime]: + if self._last_update_time: + return datetime.fromtimestamp(self._last_update_time) + else: + return None + + def set_last_update_time(self, value: types.Optional[datetime]): + if value: + self._last_update_time = time.mktime(value.timetuple()) + else: + self._last_update_time = None + + last_update_time = property(get_last_update_time, set_last_update_time) + + def __init__(self, **kwargs: typing.Any): # noqa: B027 + pass + + def start(self, **kwargs: typing.Any): + self._started = True + + def update(self, value: ValueT = None): # noqa: B027 + pass + + def finish(self): # pragma: no cover + self._finished = True + + def __del__(self): + if not self._finished and self._started: # pragma: no cover + # We're not using contextlib.suppress here because during teardown + # contextlib is not available anymore. + try: # noqa: SIM105 + self.finish() + except AttributeError: + pass + + def __getstate__(self): + return self.__dict__ + + def data(self) -> types.Dict[str, types.Any]: # pragma: no cover + raise NotImplementedError() + + def started(self) -> bool: + return self._finished or self._started + + def finished(self) -> bool: + return self._finished + + +class ProgressBarBase(types.Iterable[NumberT], ProgressBarMixinBase): + _index_counter = itertools.count() + index: int = -1 + label: str = '' + + def __init__(self, **kwargs: typing.Any): + self.index = next(self._index_counter) + super().__init__(**kwargs) + + def __repr__(self): + label = f': {self.label}' if self.label else '' + return f'<{self.__class__.__name__}#{self.index}{label}>' + + +class DefaultFdMixin(ProgressBarMixinBase): + # The file descriptor to write to. Defaults to `sys.stderr` + fd: base.TextIO = sys.stderr + #: Set the terminal to be ANSI compatible. If a terminal is ANSI + #: compatible we will automatically enable `colors` and disable + #: `line_breaks`. + is_ansi_terminal: bool | None = False + #: Whether the file descriptor is a terminal or not. This is used to + #: determine whether to use ANSI escape codes or not. + is_terminal: bool | None + #: Whether to print line breaks. This is useful for logging the + #: progressbar. When disabled the current line is overwritten. + line_breaks: bool | None = True + #: Specify the type and number of colors to support. Defaults to auto + #: detection based on the file descriptor type (i.e. interactive terminal) + #: environment variables such as `COLORTERM` and `TERM`. Color output can + #: be forced in non-interactive terminals using the + #: `PROGRESSBAR_ENABLE_COLORS` environment variable which can also be used + #: to force a specific number of colors by specifying `24bit`, `256` or + #: `16`. + #: For true (24 bit/16M) color support you can use `COLORTERM=truecolor`. + #: For 256 color support you can use `TERM=xterm-256color`. + #: For 16 colorsupport you can use `TERM=xterm`. + enable_colors: progressbar.env.ColorSupport = progressbar.env.COLOR_SUPPORT + + def __init__( + self, + fd: base.TextIO = sys.stderr, + is_terminal: bool | None = None, + line_breaks: bool | None = None, + enable_colors: progressbar.env.ColorSupport | None = None, + line_offset: int = 0, + **kwargs: typing.Any, + ): + if fd is sys.stdout: + fd = utils.streams.original_stdout + elif fd is sys.stderr: + fd = utils.streams.original_stderr + + fd = self._apply_line_offset(fd, line_offset) + self.fd = fd + self.is_ansi_terminal = progressbar.env.is_ansi_terminal(fd) + self.is_terminal = progressbar.env.is_terminal(fd, is_terminal) + self.line_breaks = self._determine_line_breaks(line_breaks) + self.enable_colors = self._determine_enable_colors(enable_colors) + + super().__init__(**kwargs) + + def _apply_line_offset( + self, + fd: base.TextIO, + line_offset: int, + ) -> base.TextIO: + if line_offset: + return progressbar.terminal.stream.LineOffsetStreamWrapper( + line_offset, + fd, + ) + else: + return fd + + def _determine_line_breaks(self, line_breaks: bool | None) -> bool | None: + if line_breaks is None: + return progressbar.env.env_flag( + 'PROGRESSBAR_LINE_BREAKS', + not self.is_terminal, + ) + else: + return line_breaks + + def _determine_enable_colors( + self, + enable_colors: progressbar.env.ColorSupport | None, + ) -> progressbar.env.ColorSupport: + """ + Determines the color support for the progress bar. + + This method checks the `enable_colors` parameter and the environment + variables `PROGRESSBAR_ENABLE_COLORS` and `FORCE_COLOR` to determine + the color support. + + If `enable_colors` is: + - `None`, it checks the environment variables and the terminal + compatibility to ANSI. + - `True`, it sets the color support to XTERM_256. + - `False`, it sets the color support to NONE. + - For different values that are not instances of + `progressbar.env.ColorSupport`, it raises a ValueError. + + Args: + enable_colors (progressbar.env.ColorSupport | None): The color + support setting from the user. It can be None, True, False, + or an instance of `progressbar.env.ColorSupport`. + + Returns: + progressbar.env.ColorSupport: The determined color support. + + Raises: + ValueError: If `enable_colors` is not None, True, False, or an + instance of `progressbar.env.ColorSupport`. + """ + color_support: progressbar.env.ColorSupport + if enable_colors is None: + colors = ( + progressbar.env.env_flag('PROGRESSBAR_ENABLE_COLORS'), + progressbar.env.env_flag('FORCE_COLOR'), + self.is_ansi_terminal, + ) + + for color_enabled in colors: + if color_enabled is not None: + if color_enabled: + color_support = progressbar.env.COLOR_SUPPORT + else: + color_support = progressbar.env.ColorSupport.NONE + break + else: + color_support = progressbar.env.ColorSupport.NONE + + elif enable_colors is True: + color_support = progressbar.env.ColorSupport.XTERM_256 + elif enable_colors is False: + color_support = progressbar.env.ColorSupport.NONE + elif isinstance(enable_colors, progressbar.env.ColorSupport): + color_support = enable_colors + else: + raise ValueError(f'Invalid color support value: {enable_colors}') + + return color_support + + def print(self, *args: types.Any, **kwargs: types.Any) -> None: + print(*args, file=self.fd, **kwargs) + + def start(self, **kwargs: typing.Any): + os_specific.set_console_mode() + super().start() + + def update(self, *args: types.Any, **kwargs: types.Any) -> None: + ProgressBarMixinBase.update(self, *args, **kwargs) + + line: str = converters.to_unicode(self._format_line()) + if not self.enable_colors: + line = utils.no_color(line) + + line = line.rstrip() + '\n' if self.line_breaks else '\r' + line + + try: # pragma: no cover + self.fd.write(line) + except UnicodeEncodeError: # pragma: no cover + self.fd.write(types.cast(str, line.encode('ascii', 'replace'))) + + def finish( + self, + *args: types.Any, + **kwargs: types.Any, + ) -> None: # pragma: no cover + os_specific.reset_console_mode() + + if self._finished: + return + + end = kwargs.pop('end', '\n') + ProgressBarMixinBase.finish(self, *args, **kwargs) + + if end and not self.line_breaks: + self.fd.write(end) + + self.fd.flush() + + def _format_line(self): + "Joins the widgets and justifies the line." + widgets = ''.join(self._to_unicode(self._format_widgets())) + + if self.left_justify: + return widgets.ljust(self.term_width) + else: + return widgets.rjust(self.term_width) + + def _format_widgets(self): + result = [] + expanding = [] + width = self.term_width + data = self.data() + + for index, widget in enumerate(self.widgets): + if isinstance( + widget, + widgets.WidgetBase, + ) and not widget.check_size(self): + continue + elif isinstance(widget, widgets.AutoWidthWidgetBase): + result.append(widget) + expanding.insert(0, index) + elif isinstance(widget, str): + result.append(widget) + width -= self.custom_len(widget) # type: ignore + else: + widget_output = converters.to_unicode(widget(self, data)) + result.append(widget_output) + width -= self.custom_len(widget_output) # type: ignore + + count = len(expanding) + while expanding: + portion = max(int(math.ceil(width * 1.0 / count)), 0) + index = expanding.pop() + widget = result[index] + count -= 1 + + widget_output = widget(self, data, portion) + width -= self.custom_len(widget_output) # type: ignore + result[index] = widget_output + + return result + + @classmethod + def _to_unicode(cls, args: typing.Any): + for arg in args: + yield converters.to_unicode(arg) + + +class ResizableMixin(ProgressBarMixinBase): + def __init__(self, term_width: int | None = None, **kwargs: typing.Any): + ProgressBarMixinBase.__init__(self, **kwargs) + + self.signal_set = False + if term_width: + self.term_width = term_width + else: # pragma: no cover + with contextlib.suppress(Exception): + self._handle_resize() + import signal + + self._prev_handle = signal.getsignal( + signal.SIGWINCH # type: ignore + ) + signal.signal( + signal.SIGWINCH, + self._handle_resize, # type: ignore + ) + self.signal_set = True + + def _handle_resize( + self, signum: int | None = None, frame: None | FrameType = None + ): + "Tries to catch resize signals sent from the terminal." + w, h = utils.get_terminal_size() + self.term_width = w + + def finish(self): # pragma: no cover + ProgressBarMixinBase.finish(self) + if self.signal_set: + with contextlib.suppress(Exception): + import signal + + signal.signal( + signal.SIGWINCH, + self._prev_handle, # type: ignore + ) + + +class StdRedirectMixin(DefaultFdMixin): + redirect_stderr: bool = False + redirect_stdout: bool = False + stdout: utils.WrappingIO | base.IO[typing.Any] + stderr: utils.WrappingIO | base.IO[typing.Any] + _stdout: base.IO[typing.Any] + _stderr: base.IO[typing.Any] + + def __init__( + self, + redirect_stderr: bool = False, + redirect_stdout: bool = False, + **kwargs, + ): + DefaultFdMixin.__init__(self, **kwargs) + self.redirect_stderr = redirect_stderr + self.redirect_stdout = redirect_stdout + self._stdout = self.stdout = sys.stdout + self._stderr = self.stderr = sys.stderr + + def start(self, *args: typing.Any, **kwargs: typing.Any): + if self.redirect_stdout: + utils.streams.wrap_stdout() + + if self.redirect_stderr: + utils.streams.wrap_stderr() + + self._stdout = utils.streams.original_stdout + self._stderr = utils.streams.original_stderr + + self.stdout = utils.streams.stdout + self.stderr = utils.streams.stderr + + utils.streams.start_capturing(self) + DefaultFdMixin.start(self, *args, **kwargs) + + def update(self, value: types.Optional[NumberT] = None): + if not self.line_breaks and utils.streams.needs_clear(): + self.fd.write('\r' + ' ' * self.term_width + '\r') + + utils.streams.flush() + DefaultFdMixin.update(self, value=value) + + def finish(self, end='\n'): + DefaultFdMixin.finish(self, end=end) + utils.streams.stop_capturing(self) + if self.redirect_stdout: + utils.streams.unwrap_stdout() + + if self.redirect_stderr: + utils.streams.unwrap_stderr() + + +class ProgressBar( + StdRedirectMixin, + ResizableMixin, + ProgressBarBase, +): + """The ProgressBar class which updates and prints the bar. + + Args: + min_value (int): The minimum/start value for the progress bar + max_value (int): The maximum/end value for the progress bar. + Defaults to `_DEFAULT_MAXVAL` + widgets (list): The widgets to render, defaults to the result of + `default_widget()` + left_justify (bool): Justify to the left if `True` or the right if + `False` + initial_value (int): The value to start with + poll_interval (float): The update interval in seconds. + Note that if your widgets include timers or animations, the actual + interval may be smaller (faster updates). Also note that updates + never happens faster than `min_poll_interval` which can be used for + reduced output in logs + min_poll_interval (float): The minimum update interval in seconds. + The bar will _not_ be updated faster than this, despite changes in + the progress, unless `force=True`. This is limited to be at least + `_MINIMUM_UPDATE_INTERVAL`. If available, it is also bound by the + environment variable PROGRESSBAR_MINIMUM_UPDATE_INTERVAL + widget_kwargs (dict): The default keyword arguments for widgets + custom_len (function): Method to override how the line width is + calculated. When using non-latin characters the width + calculation might be off by default + max_error (bool): When True the progressbar will raise an error if it + goes beyond it's set max_value. Otherwise the max_value is simply + raised when needed + prefix (str): Prefix the progressbar with the given string + suffix (str): Prefix the progressbar with the given string + variables (dict): User-defined variables variables that can be used + from a label using `format='{variables.my_var}'`. These values can + be updated using `bar.update(my_var='newValue')` This can also be + used to set initial values for variables' widgets + line_offset (int): The number of lines to offset the progressbar from + your current line. This is useful if you have other output or + multiple progressbars + + A common way of using it is like: + + >>> progress = ProgressBar().start() + >>> for i in range(100): + ... progress.update(i + 1) + ... # do something + >>> progress.finish() + + You can also use a ProgressBar as an iterator: + + >>> progress = ProgressBar() + >>> some_iterable = range(100) + >>> for i in progress(some_iterable): + ... # do something + ... pass + + Since the progress bar is incredibly customizable you can specify + different widgets of any type in any order. You can even write your own + widgets! However, since there are already a good number of widgets you + should probably play around with them before moving on to create your own + widgets. + + The term_width parameter represents the current terminal width. If the + parameter is set to an integer then the progress bar will use that, + otherwise it will attempt to determine the terminal width falling back to + 80 columns if the width cannot be determined. + + When implementing a widget's update method you are passed a reference to + the current progress bar. As a result, you have access to the + ProgressBar's methods and attributes. Although there is nothing preventing + you from changing the ProgressBar you should treat it as read only. + """ + + _iterable: types.Optional[types.Iterator] + + _DEFAULT_MAXVAL: type[base.UnknownLength] = base.UnknownLength + # update every 50 milliseconds (up to a 20 times per second) + _MINIMUM_UPDATE_INTERVAL: float = 0.050 + _last_update_time: types.Optional[float] = None + paused: bool = False + + def __init__( + self, + min_value: NumberT = 0, + max_value: ValueT = None, + widgets: types.Optional[ + types.Sequence[widgets_module.WidgetBase | str] + ] = None, + left_justify: bool = True, + initial_value: NumberT = 0, + poll_interval: types.Optional[float] = None, + widget_kwargs: types.Optional[types.Dict[str, types.Any]] = None, + custom_len: types.Callable[[str], int] = utils.len_color, + max_error=True, + prefix=None, + suffix=None, + variables=None, + min_poll_interval=None, + **kwargs, + ): # sourcery skip: low-code-quality + """Initializes a progress bar with sane defaults.""" + StdRedirectMixin.__init__(self, **kwargs) + 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') + + if max_value and min_value > types.cast(NumberT, max_value): + raise ValueError( + 'Max value needs to be bigger than the min value', + ) + self.min_value = min_value + # Legacy issue, `max_value` can be `None` before execution. After + # that it either has a value or is `UnknownLength` + self.max_value = max_value # type: ignore + self.max_error = max_error + + # Only copy the widget if it's safe to copy. Most widgets are so we + # assume this to be true + self.widgets = [] + for widget in widgets or []: + if getattr(widget, 'copy', True): + widget = deepcopy(widget) + self.widgets.append(widget) + + self.prefix = prefix + self.suffix = suffix + self.widget_kwargs = widget_kwargs or {} + self.left_justify = left_justify + self.value = initial_value + self._iterable = None + self.custom_len = custom_len # type: ignore + self.initial_start_time = kwargs.get('start_time') + self.init() + + # Convert a given timedelta to a floating point number as internal + # interval. We're not using timedelta's internally for two reasons: + # 1. Backwards compatibility (most important one) + # 2. Performance. Even though the amount of time it takes to compare a + # timedelta with a float versus a float directly is negligible, this + # comparison is run for _every_ update. With billions of updates + # (downloading a 1GiB file for example) this adds up. + poll_interval = utils.deltas_to_seconds(poll_interval, default=None) + min_poll_interval = utils.deltas_to_seconds( + min_poll_interval, + default=None, + ) + self._MINIMUM_UPDATE_INTERVAL = ( + utils.deltas_to_seconds(self._MINIMUM_UPDATE_INTERVAL) + or self._MINIMUM_UPDATE_INTERVAL + ) + + # Note that the _MINIMUM_UPDATE_INTERVAL sets the minimum in case of + # low values. + self.poll_interval = poll_interval + self.min_poll_interval = max( + min_poll_interval or self._MINIMUM_UPDATE_INTERVAL, + self._MINIMUM_UPDATE_INTERVAL, + float(os.environ.get('PROGRESSBAR_MINIMUM_UPDATE_INTERVAL', 0)), + ) # type: ignore + + # A dictionary of names that can be used by Variable and FormatWidget + self.variables = utils.AttributeDict(variables or {}) + for widget in self.widgets: + if ( + isinstance(widget, widgets_module.VariableMixin) + and widget.name not in self.variables + ): + self.variables[widget.name] = None + + @property + def dynamic_messages(self): # pragma: no cover + return self.variables + + @dynamic_messages.setter + def dynamic_messages(self, value): # pragma: no cover + self.variables = value + + def init(self): + """ + (re)initialize values to original state so the progressbar can be + used (again). + """ + self.previous_value = None + self.last_update_time = None + self.start_time = None + self.updates = 0 + self.end_time = None + self.extra = dict() + self._last_update_timer = timeit.default_timer() + + @property + def percentage(self) -> float | None: + """Return current percentage, returns None if no max_value is given. + + >>> progress = ProgressBar() + >>> progress.max_value = 10 + >>> progress.min_value = 0 + >>> progress.value = 0 + >>> progress.percentage + 0.0 + >>> + >>> progress.value = 1 + >>> progress.percentage + 10.0 + >>> progress.value = 10 + >>> progress.percentage + 100.0 + >>> progress.min_value = -10 + >>> progress.percentage + 100.0 + >>> progress.value = 0 + >>> progress.percentage + 50.0 + >>> progress.value = 5 + >>> progress.percentage + 75.0 + >>> progress.value = -5 + >>> progress.percentage + 25.0 + >>> progress.max_value = None + >>> progress.percentage + """ + if self.max_value is None or self.max_value is base.UnknownLength: + return None + elif self.max_value: + todo = self.value - self.min_value + total = self.max_value - self.min_value # type: ignore + percentage = 100.0 * todo / total + else: + percentage = 100.0 + + return percentage + + def data(self) -> types.Dict[str, types.Any]: + """ + + Returns: + dict: + - `max_value`: The maximum value (can be None with + iterators) + - `start_time`: Start time of the widget + - `last_update_time`: Last update time of the widget + - `end_time`: End time of the widget + - `value`: The current value + - `previous_value`: The previous value + - `updates`: The total update count + - `total_seconds_elapsed`: The seconds since the bar started + - `seconds_elapsed`: The seconds since the bar started modulo + 60 + - `minutes_elapsed`: The minutes since the bar started modulo + 60 + - `hours_elapsed`: The hours since the bar started modulo 24 + - `days_elapsed`: The hours since the bar started + - `time_elapsed`: The raw elapsed `datetime.timedelta` object + - `percentage`: Percentage as a float or `None` if no max_value + is available + - `dynamic_messages`: Deprecated, use `variables` instead. + - `variables`: Dictionary of user-defined variables for the + :py:class:`~progressbar.widgets.Variable`'s. + + """ + self._last_update_time = time.time() + self._last_update_timer = timeit.default_timer() + elapsed = self.last_update_time - self.start_time # type: ignore + # For Python 2.7 and higher we have _`timedelta.total_seconds`, but we + # want to support older versions as well + total_seconds_elapsed = utils.deltas_to_seconds(elapsed) + return dict( + # The maximum value (can be None with iterators) + max_value=self.max_value, + # Start time of the widget + start_time=self.start_time, + # Last update time of the widget + last_update_time=self.last_update_time, + # End time of the widget + end_time=self.end_time, + # The current value + value=self.value, + # The previous value + previous_value=self.previous_value, + # The total update count + updates=self.updates, + # The seconds since the bar started + total_seconds_elapsed=total_seconds_elapsed, + # The seconds since the bar started modulo 60 + seconds_elapsed=(elapsed.seconds % 60) + + (elapsed.microseconds / 1000000.0), + # The minutes since the bar started modulo 60 + minutes_elapsed=(elapsed.seconds / 60) % 60, + # The hours since the bar started modulo 24 + hours_elapsed=(elapsed.seconds / (60 * 60)) % 24, + # The hours since the bar started + days_elapsed=(elapsed.seconds / (60 * 60 * 24)), + # The raw elapsed `datetime.timedelta` object + time_elapsed=elapsed, + # Percentage as a float or `None` if no max_value is available + percentage=self.percentage, + # Dictionary of user-defined + # :py:class:`progressbar.widgets.Variable`'s + variables=self.variables, + # Deprecated alias for `variables` + dynamic_messages=self.variables, + ) + + def default_widgets(self): + if self.max_value: + return [ + widgets.Percentage(**self.widget_kwargs), + ' ', + widgets.SimpleProgress( + format=f'({widgets.SimpleProgress.DEFAULT_FORMAT})', + **self.widget_kwargs, + ), + ' ', + widgets.Bar(**self.widget_kwargs), + ' ', + widgets.Timer(**self.widget_kwargs), + ' ', + widgets.SmoothingETA(**self.widget_kwargs), + ] + else: + return [ + widgets.AnimatedMarker(**self.widget_kwargs), + ' ', + widgets.BouncingBar(**self.widget_kwargs), + ' ', + widgets.Counter(**self.widget_kwargs), + ' ', + widgets.Timer(**self.widget_kwargs), + ] + + def __call__(self, iterable, max_value=None): + "Use a ProgressBar to iterate through an iterable." + if max_value is not None: + self.max_value = max_value + elif self.max_value is None: + try: + self.max_value = len(iterable) + except TypeError: # pragma: no cover + self.max_value = base.UnknownLength + + self._iterable = iter(iterable) + return self + + def __iter__(self): + return self + + def __next__(self): + try: + if self._iterable is None: # pragma: no cover + value = self.value + else: + value = next(self._iterable) + + if self.start_time is None: + self.start() + else: + self.update(self.value + 1) + + except StopIteration: + self.finish() + raise + except GeneratorExit: # pragma: no cover + self.finish(dirty=True) + raise + else: + return value + + def __exit__(self, exc_type, exc_value, traceback): + self.finish(dirty=bool(exc_type)) + + def __enter__(self): + return self + + # Create an alias so that Python 2.x won't complain about not being + # an iterator. + next = __next__ + + def __iadd__(self, value): + "Updates the ProgressBar by adding a new value." + return self.increment(value) + + def increment( + self, value: NumberT = 1, *args: typing.Any, **kwargs: typing.Any + ): + self.update(self.value + value, *args, **kwargs) + return self + + def _needs_update(self): + "Returns whether the ProgressBar should redraw the line." + if self.paused: + return False + delta = timeit.default_timer() - self._last_update_timer + if delta < self.min_poll_interval: + # Prevent updating too often + return False + elif self.poll_interval and delta > self.poll_interval: + # Needs to redraw timers and animations + return True + + # Update if value increment is not large enough to + # add more bars to progressbar (according to current + # terminal width) + with contextlib.suppress(Exception): + divisor: float = self.max_value / self.term_width # type: ignore + value_divisor = self.value // divisor # type: ignore + pvalue_divisor = self.previous_value // divisor # type: ignore + if value_divisor != pvalue_divisor: + return True + # No need to redraw yet + return False + + def update( + self, value: ValueT = None, force: bool = False, **kwargs: typing.Any + ): + "Updates the ProgressBar to a new value." + if self.start_time is None: + self.start() + + if ( + value is not None + and value is not base.UnknownLength + and isinstance(value, (int, float)) + ): + if self.max_value is base.UnknownLength: + # Can't compare against unknown lengths so just update + pass + elif self.min_value > value: # type: ignore + raise ValueError( + f'Value {value} is too small. Should be ' + f'between {self.min_value} and {self.max_value}', + ) + elif self.max_value < value: # type: ignore + if self.max_error: + raise ValueError( + f'Value {value} is too large. Should be between ' + f'{self.min_value} and {self.max_value}', + ) + else: + value = typing.cast(NumberT, self.max_value) + + self.previous_value = self.value + self.value = value + + # Save the updated values for dynamic messages + variables_changed = self._update_variables(kwargs) + + if self._needs_update() or variables_changed or force: + self._update_parents(value) + + def _update_variables(self, kwargs): + variables_changed = False + for key, value_ in kwargs.items(): + if key not in self.variables: + raise TypeError( + 'update() got an unexpected variable name as argument ' + '{key!r}', + ) + elif self.variables[key] != value_: + self.variables[key] = kwargs[key] + variables_changed = True + return variables_changed + + def _update_parents(self, value: ValueT): + self.updates += 1 + ResizableMixin.update(self, value=value) + ProgressBarBase.update(self, value=value) + StdRedirectMixin.update(self, value=value) # type: ignore + + # Only flush if something was actually written + self.fd.flush() + + def start( + self, + max_value: NumberT | None = None, + init: bool = True, + *args: typing.Any, + **kwargs: typing.Any, + ) -> ProgressBar: + """Starts measuring time, and prints the bar at 0%. + + It returns self so you can use it like this: + + Args: + max_value (int): The maximum value of the progressbar + init (bool): (Re)Initialize the progressbar, this is useful if you + wish to reuse the same progressbar but can be disabled if + data needs to be persisted between runs + + >>> pbar = ProgressBar().start() + >>> for i in range(100): + ... # do something + ... pbar.update(i + 1) + >>> pbar.finish() + """ + if init: + self.init() + + # Prevent multiple starts + if self.start_time is not None: # pragma: no cover + return self + + if max_value is not None: + self.max_value = max_value + + 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) + + return self + + def _init_suffix(self): + if self.suffix: + self.widgets.append( + widgets.FormatLabel(self.suffix, new_style=True), + ) + # Unset the suffix variable after applying so an extra start() + # won't keep copying it + self.suffix = None + + def _init_prefix(self): + if self.prefix: + self.widgets.insert( + 0, + widgets.FormatLabel(self.prefix, new_style=True), + ) + # Unset the prefix variable after applying so an extra start() + # won't keep copying it + self.prefix = None + + def _verify_max_value(self): + if ( + self.max_value is not base.UnknownLength + and self.max_value is not None + and self.max_value < 0 # type: ignore + ): + raise ValueError(f'max_value out of range, got {self.max_value!r}') + + def _calculate_poll_interval(self) -> None: + self.num_intervals = max(100, self.term_width) + for widget in self.widgets: + interval: int | float | None = utils.deltas_to_seconds( + getattr(widget, 'INTERVAL', None), + default=None, + ) + if interval is not None: + self.poll_interval = min( + self.poll_interval or interval, + interval, + ) + + def finish(self, end: str = '\n', dirty: bool = False): + """ + Puts the ProgressBar bar in the finished state. + + Also flushes and disables output buffering if this was the last + progressbar running. + + Args: + end (str): The string to end the progressbar with, defaults to a + newline + dirty (bool): When True the progressbar kept the current state and + won't be set to 100 percent + """ + 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) + + @property + def currval(self): + """ + Legacy method to make progressbar-2 compatible with the original + progressbar package. + """ + warnings.warn( + 'The usage of `currval` is deprecated, please use ' + '`value` instead', + DeprecationWarning, + stacklevel=1, + ) + return self.value + + +class DataTransferBar(ProgressBar): + """A progress bar with sensible defaults for downloads etc. + + This assumes that the values its given are numbers of bytes. + """ + + def default_widgets(self): + if self.max_value: + return [ + widgets.Percentage(), + ' of ', + widgets.DataSize('max_value'), + ' ', + widgets.Bar(), + ' ', + widgets.Timer(), + ' ', + widgets.SmoothingETA(), + ] + else: + return [ + widgets.AnimatedMarker(), + ' ', + widgets.DataSize(), + ' ', + widgets.Timer(), + ] + + +class NullBar(ProgressBar): + """ + Progress bar that does absolutely nothing. Useful for single verbosity + flags. + """ + + def start(self, *args: typing.Any, **kwargs: typing.Any): + return self + + def update(self, *args: typing.Any, **kwargs: typing.Any): + return self + + def finish(self, *args: typing.Any, **kwargs: typing.Any): + return self diff --git a/progressbar/base.py b/progressbar/base.py new file mode 100644 index 00000000..24018329 --- /dev/null +++ b/progressbar/base.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import typing +from typing import IO, TextIO + + +class FalseMeta(type): + @classmethod + def __bool__(cls) -> bool: # pragma: no cover + return False + + @classmethod + def __cmp__(cls, other: typing.Any) -> int: # pragma: no cover + return -1 + + __nonzero__ = __bool__ + + +class UnknownLength(metaclass=FalseMeta): + pass + + +class Undefined(metaclass=FalseMeta): + pass + + +assert IO is not None +assert TextIO is not None + +__all__ = ( + 'FalseMeta', + 'UnknownLength', + 'Undefined', + 'IO', + 'TextIO', +) diff --git a/progressbar/env.py b/progressbar/env.py new file mode 100644 index 00000000..3871c2ed --- /dev/null +++ b/progressbar/env.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import contextlib +import enum +import os +import re +import typing + + +@typing.overload +def env_flag(name: str, default: bool) -> bool: ... + + +@typing.overload +def env_flag(name: str, default: bool | None = None) -> bool | None: ... + + +def env_flag(name: str, default: bool | None = None) -> bool | None: + """ + Accepts environt variables formatted as y/n, yes/no, 1/0, true/false, + on/off, and returns it as a boolean. + + If the environment variable is not defined, or has an unknown value, + returns `default` + """ + v = os.getenv(name) + if v and v.lower() in ('y', 'yes', 't', 'true', 'on', '1'): + return True + if v and v.lower() in ('n', 'no', 'f', 'false', 'off', '0'): + return False + return default + + +class ColorSupport(enum.IntEnum): + """Color support for the terminal.""" + + NONE = 0 + XTERM = 16 + XTERM_256 = 256 + XTERM_TRUECOLOR = 16777216 + WINDOWS = 8 + + @classmethod + def from_env(cls) -> ColorSupport: + """Get the color support from the environment. + + If any of the environment variables contain `24bit` or `truecolor`, + we will enable true color/24 bit support. If they contain `256`, we + will enable 256 color/8 bit support. If they contain `xterm`, we will + enable 16 color support. Otherwise, we will assume no color support. + + If `JUPYTER_COLUMNS` or `JUPYTER_LINES` or `JPY_PARENT_PID` is set, we + will assume true color support. + + Note that the highest available value will be used! Having + `COLORTERM=truecolor` will override `TERM=xterm-256color`. + """ + variables = ( + 'FORCE_COLOR', + 'PROGRESSBAR_ENABLE_COLORS', + 'COLORTERM', + 'TERM', + ) + + if JUPYTER: + # Jupyter notebook always supports true color. + return cls.XTERM_TRUECOLOR + elif os.name == 'nt': + # We can't reliably detect true color support on Windows, so we + # will assume it is supported if the console is configured to + # support it. + from .terminal.os_specific import windows + + if ( + windows.get_console_mode() + & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + ): + return cls.XTERM_TRUECOLOR + else: + return cls.WINDOWS # pragma: no cover + + support = cls.NONE + for variable in variables: + value = os.environ.get(variable) + if value is None: + continue + elif value in {'truecolor', '24bit'}: + # Truecolor support, we don't need to check anything else. + support = cls.XTERM_TRUECOLOR + break + elif '256' in value: + support = max(cls.XTERM_256, support) + elif value == 'xterm': + support = max(cls.XTERM, support) + + return support + + +def is_ansi_terminal( + fd: typing.IO[typing.Any], + is_terminal: bool | None = None, +) -> bool | None: # pragma: no cover + if is_terminal is None: + # Jupyter Notebooks support progress bars + if JUPYTER: + is_terminal = True + # This works for newer versions of pycharm only. With older versions + # there is no way to check. + elif os.environ.get('PYCHARM_HOSTED') == '1' and not os.environ.get( + 'PYTEST_CURRENT_TEST' + ): + is_terminal = True + + if is_terminal is None: + # check if we are writing to a terminal or not. typically a file object + # is going to return False if the instance has been overridden and + # isatty has not been defined we have no way of knowing so we will not + # use ansi. ansi terminals will typically define one of the 2 + # environment variables. + with contextlib.suppress(Exception): + is_tty: bool = fd.isatty() + # Try and match any of the huge amount of Linux/Unix ANSI consoles + if is_tty and ANSI_TERM_RE.match(os.environ.get('TERM', '')): + is_terminal = True + # ANSICON is a Windows ANSI compatible console + elif 'ANSICON' in os.environ: + is_terminal = True + elif os.name == 'nt': + from .terminal.os_specific import windows + + return bool( + windows.get_console_mode() + & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT, + ) + else: + is_terminal = None + + return is_terminal + + +def is_terminal( + fd: typing.IO[typing.Any], + is_terminal: bool | None = None, +) -> bool | None: + if is_terminal is None: + # Full ansi support encompasses what we expect from a terminal + is_terminal = is_ansi_terminal(fd) or None + + if is_terminal is None: + # Allow a environment variable override + is_terminal = env_flag('PROGRESSBAR_IS_TERMINAL', None) + + if is_terminal is None: # pragma: no cover + # Bare except because a lot can go wrong on different systems. If we do + # get a TTY we know this is a valid terminal + try: + is_terminal = fd.isatty() + except Exception: + is_terminal = False + + return is_terminal + + +# Enable Windows full color mode if possible +if os.name == 'nt': + pass + + # os_specific.set_console_mode() + +JUPYTER = bool( + os.environ.get('JUPYTER_COLUMNS') + or os.environ.get('JUPYTER_LINES') + or os.environ.get('JPY_PARENT_PID') +) +COLOR_SUPPORT = ColorSupport.from_env() +ANSI_TERMS = ( + '([xe]|bv)term', + '(sco)?ansi', + 'cygwin', + 'konsole', + 'linux', + 'rxvt', + 'screen', + 'tmux', + 'vt(10[02]|220|320)', +) +ANSI_TERM_RE: re.Pattern[str] = re.compile( + f"^({'|'.join(ANSI_TERMS)})", re.IGNORECASE +) diff --git a/progressbar/multi.py b/progressbar/multi.py new file mode 100644 index 00000000..8900b89e --- /dev/null +++ b/progressbar/multi.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import enum +import io +import itertools +import operator +import sys +import threading +import time +import timeit +import typing +from datetime import timedelta + +import python_utils + +from . import bar, terminal +from .terminal import stream + +SortKeyFunc = typing.Callable[[bar.ProgressBar], typing.Any] + + +class SortKey(str, enum.Enum): + """ + Sort keys for the MultiBar. + + This is a string enum, so you can use any + progressbar attribute or property as a sort key. + + Note that the multibar defaults to lazily rendering only the changed + progressbars. This means that sorting by dynamic attributes such as + `value` might result in more rendering which can have a small performance + impact. + """ + + CREATED = 'index' + LABEL = 'label' + VALUE = 'value' + PERCENTAGE = 'percentage' + + +class MultiBar(typing.Dict[str, bar.ProgressBar]): + fd: typing.TextIO + _buffer: io.StringIO + + #: The format for the label to append/prepend to the progressbar + label_format: str + #: Automatically prepend the label to the progressbars + prepend_label: bool + #: Automatically append the label to the progressbars + append_label: bool + #: If `initial_format` is `None`, the progressbar rendering is used + # which will *start* the progressbar. That means the progressbar will + # have no knowledge of your data and will run as an infinite progressbar. + initial_format: str | None + #: If `finished_format` is `None`, the progressbar rendering is used. + finished_format: str | None + + #: The multibar updates at a fixed interval regardless of the progressbar + # updates + update_interval: float + remove_finished: float | None + + #: The kwargs passed to the progressbar constructor + progressbar_kwargs: dict[str, typing.Any] + + #: The progressbar sorting key function + sort_keyfunc: SortKeyFunc + + _previous_output: list[str] + _finished_at: dict[bar.ProgressBar, float] + _labeled: set[bar.ProgressBar] + _print_lock: threading.RLock = threading.RLock() + _thread: threading.Thread | None = None + _thread_finished: threading.Event = threading.Event() + _thread_closed: threading.Event = threading.Event() + + def __init__( + self, + bars: typing.Iterable[tuple[str, bar.ProgressBar]] | None = None, + fd: typing.TextIO = sys.stderr, + prepend_label: bool = True, + append_label: bool = False, + label_format='{label:20.20} ', + initial_format: str | None = '{label:20.20} Not yet started', + finished_format: str | None = None, + update_interval: float = 1 / 60.0, # 60fps + show_initial: bool = True, + show_finished: bool = True, + remove_finished: timedelta | float = timedelta(seconds=3600), + sort_key: str | SortKey = SortKey.CREATED, + sort_reverse: bool = True, + sort_keyfunc: SortKeyFunc | None = None, + **progressbar_kwargs, + ): + self.fd = fd + + self.prepend_label = prepend_label + self.append_label = append_label + self.label_format = label_format + self.initial_format = initial_format + self.finished_format = finished_format + + self.update_interval = update_interval + + self.show_initial = show_initial + self.show_finished = show_finished + self.remove_finished = python_utils.delta_to_seconds_or_none( + remove_finished, + ) + + self.progressbar_kwargs = progressbar_kwargs + + if sort_keyfunc is None: + sort_keyfunc = operator.attrgetter(sort_key) + + self.sort_keyfunc = sort_keyfunc + self.sort_reverse = sort_reverse + + self._labeled = set() + self._finished_at = {} + self._previous_output = [] + self._buffer = io.StringIO() + + super().__init__(bars or {}) + + 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 + 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 + + # Just in case someone is using a progressbar with a custom + # constructor and forgot to call the super constructor + if bar.index == -1: + bar.index = next(bar._index_counter) + + super().__setitem__(key, bar) + + def __delitem__(self, key): + """Remove a progressbar from the multibar.""" + super().__delitem__(key) + self._finished_at.pop(key, None) + self._labeled.discard(key) + + def __getitem__(self, key): + """Get (and create if needed) a progressbar from the multibar.""" + try: + return super().__getitem__(key) + except KeyError: + progress = bar.ProgressBar(**self.progressbar_kwargs) + self[key] = progress + return progress + + def _label_bar(self, bar: bar.ProgressBar): + if bar in self._labeled: # pragma: no branch + return + + assert bar.widgets, 'Cannot prepend label to empty progressbar' + + if self.prepend_label: # pragma: no branch + self._labeled.add(bar) + bar.widgets.insert(0, self.label_format.format(label=bar.label)) + + if self.append_label and bar not in self._labeled: # pragma: no branch + self._labeled.add(bar) + bar.widgets.append(self.label_format.format(label=bar.label)) + + def render(self, flush: bool = True, force: bool = False): + """Render the multibar to the given stream.""" + now = timeit.default_timer() + expired = now - self.remove_finished if self.remove_finished else None + + # sourcery skip: list-comprehension + output: list[str] = [] + for bar_ in self.get_sorted_bars(): + if not bar_.started() and not self.show_initial: + continue + + output.extend( + iter(self._render_bar(bar_, expired=expired, now=now)), + ) + + with self._print_lock: + # Clear the previous output if progressbars have been removed + for i in range(len(output), len(self._previous_output)): + self._buffer.write( + terminal.clear_line(i + 1), + ) # pragma: no cover + + # Add empty lines to the end of the output if progressbars have + # been added + for _ in range(len(self._previous_output), len(output)): + # Adding a new line so we don't overwrite previous output + self._buffer.write('\n') + + for i, (previous, current) in enumerate( + itertools.zip_longest( + self._previous_output, + output, + fillvalue='', + ), + ): + if previous != current or force: # pragma: no branch + self.print( + '\r' + current.strip(), + offset=i + 1, + end='', + clear=False, + flush=False, + ) + + self._previous_output = output + + if flush: # pragma: no branch + self.flush() + + def _render_bar( + self, + bar_: bar.ProgressBar, + now, + expired, + ) -> typing.Iterable[str]: + def update(force=True, write=True): # pragma: no cover + self._label_bar(bar_) + bar_.update(force=force) + if write: + yield typing.cast(stream.LastLineStream, bar_.fd).line + + if bar_.finished(): + yield from self._render_finished_bar(bar_, now, expired, update) + + elif bar_.started(): + update() + else: + if self.initial_format is None: + bar_.start() + update() + else: + yield self.initial_format.format(label=bar_.label) + + def _render_finished_bar( + self, + bar_: bar.ProgressBar, + now, + expired, + update, + ) -> typing.Iterable[str]: + if bar_ not in self._finished_at: + self._finished_at[bar_] = now + # Force update to get the finished format + update(write=False) + + if ( + self.remove_finished + and expired is not None + and expired >= self._finished_at[bar_] + ): + del self[bar_.label] + return + + if not self.show_finished: + return + + if bar_.finished(): # pragma: no branch + if self.finished_format is None: + update(force=False) + else: # pragma: no cover + yield self.finished_format.format(label=bar_.label) + + def print( + self, + *args, + end='\n', + offset=None, + flush=True, + clear=True, + **kwargs, + ): + """ + Print to the progressbar stream without overwriting the progressbars. + + Args: + end: The string to append to the end of the output + offset: The number of lines to offset the output by. If None, the + output will be printed above the progressbars + flush: Whether to flush the output to the stream + clear: If True, the line will be cleared before printing. + **kwargs: Additional keyword arguments to pass to print + """ + with self._print_lock: + if offset is None: + offset = len(self._previous_output) + + if not clear: + self._buffer.write(terminal.PREVIOUS_LINE(offset)) + + if clear: + self._buffer.write(terminal.PREVIOUS_LINE(offset)) + self._buffer.write(terminal.CLEAR_LINE_ALL()) + + print(*args, **kwargs, file=self._buffer, end=end) + + if clear: + self._buffer.write(terminal.CLEAR_SCREEN_TILL_END()) + for line in self._previous_output: + self._buffer.write(line.strip()) + self._buffer.write('\n') + + else: + self._buffer.write(terminal.NEXT_LINE(offset)) + + if flush: + self.flush() + + def flush(self): + self.fd.write(self._buffer.getvalue()) + self._buffer.truncate(0) + self.fd.flush() + + def run(self, join=True): + """ + Start the multibar render loop and run the progressbars until they + have force _thread_finished. + """ + while not self._thread_finished.is_set(): # pragma: no branch + self.render() + time.sleep(self.update_interval) + + if join or self._thread_closed.is_set(): + # If the thread is closed, we need to check if the progressbars + # have finished. If they have, we can exit the loop + for bar_ in self.values(): # pragma: no cover + if not bar_.finished(): + break + else: + # Render one last time to make sure the progressbars are + # correctly finished + self.render(force=True) + return + + def start(self): + assert not self._thread, 'Multibar already started' + self._thread_closed.set() + self._thread = threading.Thread(target=self.run, args=(False,)) + self._thread.start() + + def join(self, timeout=None): + if self._thread is not None: + self._thread_closed.set() + self._thread.join(timeout=timeout) + self._thread = None + + def stop(self, timeout: float | None = None): + self._thread_finished.set() + self.join(timeout=timeout) + + def get_sorted_bars(self): + return sorted( + self.values(), + key=self.sort_keyfunc, + reverse=self.sort_reverse, + ) + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.join() diff --git a/progressbar/py.typed b/progressbar/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/progressbar/shortcuts.py b/progressbar/shortcuts.py new file mode 100644 index 00000000..edf0a5b1 --- /dev/null +++ b/progressbar/shortcuts.py @@ -0,0 +1,22 @@ +from . import bar + + +def progressbar( + iterator, + min_value: int = 0, + max_value=None, + widgets=None, + prefix=None, + suffix=None, + **kwargs, +): + progressbar = bar.ProgressBar( + min_value=min_value, + max_value=max_value, + widgets=widgets, + prefix=prefix, + suffix=suffix, + **kwargs, + ) + + yield from progressbar(iterator) diff --git a/progressbar/terminal/__init__.py b/progressbar/terminal/__init__.py new file mode 100644 index 00000000..037cce63 --- /dev/null +++ b/progressbar/terminal/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +from .base import * # noqa F403 +from .stream import * # noqa F403 diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py new file mode 100644 index 00000000..1141e52e --- /dev/null +++ b/progressbar/terminal/base.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +import abc +import collections +import colorsys +import enum +import threading +from collections import defaultdict + +# Ruff is being stupid and doesn't understand `ClassVar` if it comes from the +# `types` module +from typing import ClassVar + +from python_utils import converters, types + +from .. import ( + base as pbase, + env, +) +from .os_specific import getch + +ESC = '\x1b' + + +class CSI: + _code: str + _template = ESC + '[{args}{code}' + + def __init__(self, code: str, *default_args) -> None: + self._code = code + self._default_args = default_args + + def __call__(self, *args): + return self._template.format( + args=';'.join(map(str, args or self._default_args)), + code=self._code, + ) + + def __str__(self): + return self() + + +class CSINoArg(CSI): + def __call__(self): + return super().__call__() + + +#: Cursor Position [row;column] (default = [1,1]) +CUP: CSI = CSI('H', 1, 1) + +#: Cursor Up Ps Times (default = 1) (CUU) +UP: CSI = CSI('A', 1) + +#: Cursor Down Ps Times (default = 1) (CUD) +DOWN: CSI = CSI('B', 1) + +#: Cursor Forward Ps Times (default = 1) (CUF) +RIGHT: CSI = CSI('C', 1) + +#: Cursor Backward Ps Times (default = 1) (CUB) +LEFT: CSI = CSI('D', 1) + +#: Cursor Next Line Ps Times (default = 1) (CNL) +#: Same as Cursor Down Ps Times +NEXT_LINE: CSI = CSI('E', 1) + +#: Cursor Preceding Line Ps Times (default = 1) (CPL) +#: Same as Cursor Up Ps Times +PREVIOUS_LINE: CSI = CSI('F', 1) + +#: Cursor Character Absolute [column] (default = [row,1]) (CHA) +COLUMN: CSI = CSI('G', 1) + +#: Erase in Display (ED) +CLEAR_SCREEN: CSI = CSI('J', 0) + +#: Erase till end of screen +CLEAR_SCREEN_TILL_END: CSINoArg = CSINoArg('0J') + +#: Erase till start of screen +CLEAR_SCREEN_TILL_START: CSINoArg = CSINoArg('1J') + +#: Erase whole screen +CLEAR_SCREEN_ALL: CSINoArg = CSINoArg('2J') + +#: Erase whole screen and history +CLEAR_SCREEN_ALL_AND_HISTORY: CSINoArg = CSINoArg('3J') + +#: Erase in Line (EL) +CLEAR_LINE_ALL: CSI = CSI('K') + +#: Erase in Line from Cursor to End of Line (default) +CLEAR_LINE_RIGHT: CSINoArg = CSINoArg('0K') + +#: Erase in Line from Cursor to Beginning of Line +CLEAR_LINE_LEFT: CSINoArg = CSINoArg('1K') + +#: Erase Line containing Cursor +CLEAR_LINE: CSINoArg = CSINoArg('2K') + +#: Scroll up Ps lines (default = 1) (SU) +#: Scroll down Ps lines (default = 1) (SD) +SCROLL_UP: CSI = CSI('S') +SCROLL_DOWN: CSI = CSI('T') + +#: Save Cursor Position (SCP) +SAVE_CURSOR: CSINoArg = CSINoArg('s') + +#: Restore Cursor Position (RCP) +RESTORE_CURSOR: CSINoArg = CSINoArg('u') + +#: Cursor Visibility (DECTCEM) +HIDE_CURSOR: CSINoArg = CSINoArg('?25l') +SHOW_CURSOR: CSINoArg = CSINoArg('?25h') + + +# +# UP = CSI + '{n}A' # Cursor Up +# DOWN = CSI + '{n}B' # Cursor Down +# RIGHT = CSI + '{n}C' # Cursor Forward +# LEFT = CSI + '{n}D' # Cursor Backward +# NEXT = CSI + '{n}E' # Cursor Next Line +# PREV = CSI + '{n}F' # Cursor Previous Line +# MOVE_COLUMN = CSI + '{n}G' # Cursor Horizontal Absolute +# MOVE = CSI + '{row};{column}H' # Cursor Position [row;column] (default = [ +# 1,1]) +# +# CLEAR = CSI + '{n}J' # Clear (part of) the screen +# CLEAR_BOTTOM = CLEAR.format(n=0) # Clear from cursor to end of screen +# CLEAR_TOP = CLEAR.format(n=1) # Clear from cursor to beginning of screen +# CLEAR_SCREEN = CLEAR.format(n=2) # Clear Screen +# CLEAR_WIPE = CLEAR.format(n=3) # Clear Screen and scrollback buffer +# +# CLEAR_LINE = CSI + '{n}K' # Erase in Line +# CLEAR_LINE_RIGHT = CLEAR_LINE.format(n=0) # Clear from cursor to end of line +# CLEAR_LINE_LEFT = CLEAR_LINE.format(n=1) # Clear from cursor to beginning +# of line +# CLEAR_LINE_ALL = CLEAR_LINE.format(n=2) # Clear Line + + +def clear_line(n): + return UP(n) + CLEAR_LINE_ALL() + DOWN(n) + + +# Report Cursor Position (CPR), response = [row;column] as row;columnR +class _CPR(str): # pragma: no cover + _response_lock = threading.Lock() + + def __call__(self, stream) -> tuple[int, int]: + res: str = '' + + with self._response_lock: + stream.write(str(self)) + stream.flush() + + while not res.endswith('R'): + char = getch() + + if char is not None: + res += char + + res_list = res[2:-1].split(';') + + res_list = tuple( + int(item) if item.isdigit() else item for item in res_list + ) + + if len(res_list) == 1: + return types.cast(types.Tuple[int, int], res_list[0]) + + return types.cast(types.Tuple[int, int], tuple(res_list)) + + def row(self, stream) -> int: + row, _ = self(stream) + return row + + def column(self, stream) -> int: + _, column = self(stream) + return column + + +class WindowsColors(enum.Enum): + BLACK = 0, 0, 0 + BLUE = 0, 0, 128 + GREEN = 0, 128, 0 + CYAN = 0, 128, 128 + RED = 128, 0, 0 + MAGENTA = 128, 0, 128 + YELLOW = 128, 128, 0 + GREY = 192, 192, 192 + INTENSE_BLACK = 128, 128, 128 + INTENSE_BLUE = 0, 0, 255 + INTENSE_GREEN = 0, 255, 0 + INTENSE_CYAN = 0, 255, 255 + INTENSE_RED = 255, 0, 0 + INTENSE_MAGENTA = 255, 0, 255 + INTENSE_YELLOW = 255, 255, 0 + INTENSE_WHITE = 255, 255, 255 + + @staticmethod + def from_rgb(rgb: types.Tuple[int, int, int]) -> WindowsColors: + """ + Find the closest WindowsColors to the given RGB color. + + >>> WindowsColors.from_rgb((0, 0, 0)) + + + >>> WindowsColors.from_rgb((255, 255, 255)) + + + >>> WindowsColors.from_rgb((0, 255, 0)) + + + >>> WindowsColors.from_rgb((45, 45, 45)) + + + >>> WindowsColors.from_rgb((128, 0, 128)) + + """ + + def color_distance(rgb1, rgb2): + return sum((c1 - c2) ** 2 for c1, c2 in zip(rgb1, rgb2)) + + return min( + WindowsColors, + key=lambda color: color_distance(color.value, rgb), + ) + + +class WindowsColor: + """ + Windows compatible color class for when ANSI is not supported. + Currently a no-op because it is not possible to buffer these colors. + + >>> WindowsColor(WindowsColors.RED)('test') + 'test' + """ + + __slots__ = ('color',) + + def __init__(self, color: Color) -> None: + self.color = color + + def __call__(self, text): + return text + ## In the future we might want to use this, but it requires direct + ## printing to stdout and all of our surrounding functions expect + ## buffered output so it's not feasible right now. Additionally, + ## recent Windows versions all support ANSI codes without issue so + ## there is little need. + # from progressbar.terminal.os_specific import windows + # windows.print_color(text, WindowsColors.from_rgb(self.color.rgb)) + + +class RGB(collections.namedtuple('RGB', ['red', 'green', 'blue'])): + __slots__ = () + + def __str__(self): + return self.rgb + + @property + def rgb(self) -> str: + return f'rgb({self.red}, {self.green}, {self.blue})' + + @property + def hex(self) -> str: + return f'#{self.red:02x}{self.green:02x}{self.blue:02x}' + + @property + def to_ansi_16(self) -> int: + # Using int instead of round because it maps slightly better + red = int(self.red / 255) + green = int(self.green / 255) + blue = int(self.blue / 255) + return (blue << 2) | (green << 1) | red + + @property + def to_ansi_256(self) -> int: + red = round(self.red / 255 * 5) + green = round(self.green / 255 * 5) + blue = round(self.blue / 255 * 5) + return 16 + 36 * red + 6 * green + blue + + @property + def to_windows(self): + """ + Convert an RGB color (0-255 per channel) to the closest color in the + Windows 16 color scheme. + """ + return WindowsColors.from_rgb((self.red, self.green, self.blue)) + + def interpolate(self, end: RGB, step: float) -> RGB: + return RGB( + int(self.red + (end.red - self.red) * step), + int(self.green + (end.green - self.green) * step), + int(self.blue + (end.blue - self.blue) * step), + ) + + +class HSL(collections.namedtuple('HSL', ['hue', 'saturation', 'lightness'])): + """ + Hue, Saturation, Lightness color. + + Hue is a value between 0 and 360, saturation and lightness are between 0(%) + and 100(%). + + """ + + __slots__ = () + + @classmethod + def from_rgb(cls, rgb: RGB) -> HSL: + """ + Convert a 0-255 RGB color to a 0-255 HLS color. + """ + hls = colorsys.rgb_to_hls( + rgb.red / 255, + rgb.green / 255, + rgb.blue / 255, + ) + return cls( + round(hls[0] * 360), + round(hls[2] * 100), + round(hls[1] * 100), + ) + + def interpolate(self, end: HSL, step: float) -> HSL: + return HSL( + self.hue + (end.hue - self.hue) * step, + self.lightness + (end.lightness - self.lightness) * step, + self.saturation + (end.saturation - self.saturation) * step, + ) + + +class ColorBase(abc.ABC): + def get_color(self, value: float) -> Color: + raise NotImplementedError() + + +class Color( + collections.namedtuple( + 'Color', + [ + 'rgb', + 'hls', + 'name', + 'xterm', + ], + ), + ColorBase, +): + """ + Color base class. + + This class contains the colors in RGB (Red, Green, Blue), HSL (Hue, + Lightness, Saturation) and Xterm (8-bit) formats. It also contains the + color name. + + To make a custom color the only required arguments are the RGB values. + The other values will be automatically interpolated from that if needed, + but you can be more explicitly if you wish. + """ + + __slots__ = () + + def __call__(self, value: str) -> str: + return self.fg(value) + + @property + def fg(self) -> SGRColor | WindowsColor: + if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS: + return WindowsColor(self) + else: + return SGRColor(self, 38, 39) + + @property + def bg(self) -> DummyColor | SGRColor: + if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS: + return DummyColor() + else: + return SGRColor(self, 48, 49) + + @property + def underline(self) -> DummyColor | SGRColor: + if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS: + return DummyColor() + else: + return SGRColor(self, 58, 59) + + @property + def ansi(self) -> types.Optional[str]: + if ( + env.COLOR_SUPPORT is env.ColorSupport.XTERM_TRUECOLOR + ): # pragma: no branch + return f'2;{self.rgb.red};{self.rgb.green};{self.rgb.blue}' + + if self.xterm: # pragma: no branch + color = self.xterm + elif ( + env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 + ): # pragma: no branch + color = self.rgb.to_ansi_256 + elif env.COLOR_SUPPORT is env.ColorSupport.XTERM: # pragma: no branch + color = self.rgb.to_ansi_16 + else: # pragma: no branch + return None + + return f'5;{color}' + + def interpolate(self, end: Color, step: float) -> Color: + return Color( + self.rgb.interpolate(end.rgb, step), + self.hls.interpolate(end.hls, step), + self.name if step < 0.5 else end.name, + self.xterm if step < 0.5 else end.xterm, + ) + + def __str__(self): + return self.name + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({self.name!r})' + + def __hash__(self) -> int: + return hash(self.rgb) + + +class Colors: + by_name: ClassVar[defaultdict[str, types.List[Color]]] = ( + collections.defaultdict(list) + ) + by_lowername: ClassVar[defaultdict[str, types.List[Color]]] = ( + collections.defaultdict(list) + ) + by_hex: ClassVar[defaultdict[str, types.List[Color]]] = ( + collections.defaultdict(list) + ) + by_rgb: ClassVar[defaultdict[RGB, types.List[Color]]] = ( + collections.defaultdict(list) + ) + by_hls: ClassVar[defaultdict[HSL, types.List[Color]]] = ( + collections.defaultdict(list) + ) + by_xterm: ClassVar[dict[int, Color]] = dict() + + @classmethod + def register( + cls, + rgb: RGB, + hls: types.Optional[HSL] = None, + name: types.Optional[str] = None, + xterm: types.Optional[int] = None, + ) -> Color: + color = Color(rgb, hls, name, xterm) + + if name: + cls.by_name[name].append(color) + cls.by_lowername[name.lower()].append(color) + + if hls is None: + hls = HSL.from_rgb(rgb) + + cls.by_hex[rgb.hex].append(color) + cls.by_rgb[rgb].append(color) + cls.by_hls[hls].append(color) + + if xterm is not None: + cls.by_xterm[xterm] = color + + return color + + @classmethod + def interpolate(cls, color_a: Color, color_b: Color, step: float) -> Color: + return color_a.interpolate(color_b, step) + + +class ColorGradient(ColorBase): + def __init__(self, *colors: Color, interpolate=Colors.interpolate) -> None: + assert colors + self.colors = colors + self.interpolate = interpolate + + def __call__(self, value: float) -> Color: + return self.get_color(value) + + def get_color(self, value: float) -> Color: + "Map a value from 0 to 1 to a color." + if ( + value == pbase.Undefined + or value == pbase.UnknownLength + or value <= 0 + ): + return self.colors[0] + elif value >= 1: + return self.colors[-1] + + max_color_idx = len(self.colors) - 1 + if max_color_idx == 0: + return self.colors[0] + elif self.interpolate: + if max_color_idx > 1: + index = round( + converters.remap(value, 0, 1, 0, max_color_idx - 1), + ) + else: + index = 0 + + step = converters.remap( + value, + index / (max_color_idx), + (index + 1) / (max_color_idx), + 0, + 1, + ) + color = self.interpolate( + self.colors[index], + self.colors[index + 1], + float(step), + ) + else: + index = round(converters.remap(value, 0, 1, 0, max_color_idx)) + color = self.colors[index] + + return color + + +OptionalColor = types.Union[Color, ColorGradient, None] + + +def get_color(value: float, color: OptionalColor) -> Color | None: + if isinstance(color, ColorGradient): + color = color(value) + return color + + +def apply_colors( + text: str, + percentage: float | None = None, + *, + fg: OptionalColor = None, + bg: OptionalColor = None, + fg_none: Color | None = None, + bg_none: Color | None = None, + **kwargs: types.Any, +) -> str: + """Apply colors/gradients to a string depending on the given percentage. + + When percentage is `None`, the `fg_none` and `bg_none` colors will be used. + Otherwise, the `fg` and `bg` colors will be used. If the colors are + gradients, the color will be interpolated depending on the percentage. + """ + if percentage is None: + if fg_none is not None: + text = fg_none.fg(text) + if bg_none is not None: + text = bg_none.bg(text) + elif fg is not None or bg is not None: + fg = get_color(percentage * 0.01, fg) + bg = get_color(percentage * 0.01, bg) + + if fg is not None: # pragma: no branch + text = fg.fg(text) + if bg is not None: # pragma: no branch + text = bg.bg(text) + + return text + + +class DummyColor: + def __call__(self, text): + return text + + def __repr__(self) -> str: + return 'DummyColor()' + + +class SGR(CSI): + _start_code: int + _end_code: int + _code = 'm' + __slots__ = '_start_code', '_end_code' + + def __init__(self, start_code: int, end_code: int) -> None: + self._start_code = start_code + self._end_code = end_code + + @property + def _start_template(self): + return super().__call__(self._start_code) + + @property + def _end_template(self): + return super().__call__(self._end_code) + + def __call__(self, text, *args): + return self._start_template + text + self._end_template + + +class SGRColor(SGR): + __slots__ = '_color', '_start_code', '_end_code' + + def __init__(self, color: Color, start_code: int, end_code: int) -> None: + self._color = color + super().__init__(start_code, end_code) + + @property + def _start_template(self): + return CSI.__call__(self, self._start_code, self._color.ansi) + + +encircled: SGR = SGR(52, 54) +framed: SGR = SGR(51, 54) +overline: SGR = SGR(53, 55) +bold: SGR = SGR(1, 22) +gothic: SGR = SGR(20, 10) +italic: SGR = SGR(3, 23) +strike_through: SGR = SGR(9, 29) +fast_blink: SGR = SGR(6, 25) +slow_blink: SGR = SGR(5, 25) +underline: SGR = SGR(4, 24) +double_underline: SGR = SGR(21, 24) +faint: SGR = SGR(2, 22) +inverse: SGR = SGR(7, 27) diff --git a/progressbar/terminal/colors.py b/progressbar/terminal/colors.py new file mode 100644 index 00000000..37e5ea90 --- /dev/null +++ b/progressbar/terminal/colors.py @@ -0,0 +1,1072 @@ +from __future__ import annotations + +# Based on: https://www.ditig.com/256-colors-cheat-sheet +import os + +from progressbar.terminal.base import HSL, RGB, ColorGradient, Colors + +black = Colors.register(RGB(0, 0, 0), HSL(0, 0, 0), 'Black', 0) +maroon = Colors.register(RGB(128, 0, 0), HSL(0, 100, 25), 'Maroon', 1) +green = Colors.register(RGB(0, 128, 0), HSL(120, 100, 25), 'Green', 2) +olive = Colors.register(RGB(128, 128, 0), HSL(60, 100, 25), 'Olive', 3) +navy = Colors.register(RGB(0, 0, 128), HSL(240, 100, 25), 'Navy', 4) +purple = Colors.register(RGB(128, 0, 128), HSL(300, 100, 25), 'Purple', 5) +teal = Colors.register(RGB(0, 128, 128), HSL(180, 100, 25), 'Teal', 6) +silver = Colors.register(RGB(192, 192, 192), HSL(0, 0, 75), 'Silver', 7) +grey = Colors.register(RGB(128, 128, 128), HSL(0, 0, 50), 'Grey', 8) +red = Colors.register(RGB(255, 0, 0), HSL(0, 100, 50), 'Red', 9) +lime = Colors.register(RGB(0, 255, 0), HSL(120, 100, 50), 'Lime', 10) +yellow = Colors.register(RGB(255, 255, 0), HSL(60, 100, 50), 'Yellow', 11) +blue = Colors.register(RGB(0, 0, 255), HSL(240, 100, 50), 'Blue', 12) +fuchsia = Colors.register(RGB(255, 0, 255), HSL(300, 100, 50), 'Fuchsia', 13) +aqua = Colors.register(RGB(0, 255, 255), HSL(180, 100, 50), 'Aqua', 14) +white = Colors.register(RGB(255, 255, 255), HSL(0, 0, 100), 'White', 15) +grey0 = Colors.register(RGB(0, 0, 0), HSL(0, 0, 0), 'Grey0', 16) +navy_blue = Colors.register(RGB(0, 0, 95), HSL(240, 100, 18), 'NavyBlue', 17) +dark_blue = Colors.register(RGB(0, 0, 135), HSL(240, 100, 26), 'DarkBlue', 18) +blue3 = Colors.register(RGB(0, 0, 175), HSL(240, 100, 34), 'Blue3', 19) +blue3 = Colors.register(RGB(0, 0, 215), HSL(240, 100, 42), 'Blue3', 20) +blue1 = Colors.register(RGB(0, 0, 255), HSL(240, 100, 50), 'Blue1', 21) +dark_green = Colors.register(RGB(0, 95, 0), HSL(120, 100, 18), 'DarkGreen', 22) +deep_sky_blue4 = Colors.register( + RGB(0, 95, 95), + HSL(180, 100, 18), + 'DeepSkyBlue4', + 23, +) +deep_sky_blue4 = Colors.register( + RGB(0, 95, 135), + HSL(97, 100, 26), + 'DeepSkyBlue4', + 24, +) +deep_sky_blue4 = Colors.register( + RGB(0, 95, 175), + HSL(7, 100, 34), + 'DeepSkyBlue4', + 25, +) +dodger_blue3 = Colors.register( + RGB(0, 95, 215), + HSL(13, 100, 42), + 'DodgerBlue3', + 26, +) +dodger_blue2 = Colors.register( + RGB(0, 95, 255), + HSL(17, 100, 50), + 'DodgerBlue2', + 27, +) +green4 = Colors.register(RGB(0, 135, 0), HSL(120, 100, 26), 'Green4', 28) +spring_green4 = Colors.register( + RGB(0, 135, 95), + HSL(62, 100, 26), + 'SpringGreen4', + 29, +) +turquoise4 = Colors.register( + RGB(0, 135, 135), + HSL(180, 100, 26), + 'Turquoise4', + 30, +) +deep_sky_blue3 = Colors.register( + RGB(0, 135, 175), + HSL(93, 100, 34), + 'DeepSkyBlue3', + 31, +) +deep_sky_blue3 = Colors.register( + RGB(0, 135, 215), + HSL(2, 100, 42), + 'DeepSkyBlue3', + 32, +) +dodger_blue1 = Colors.register( + RGB(0, 135, 255), + HSL(8, 100, 50), + 'DodgerBlue1', + 33, +) +green3 = Colors.register(RGB(0, 175, 0), HSL(120, 100, 34), 'Green3', 34) +spring_green3 = Colors.register( + RGB(0, 175, 95), + HSL(52, 100, 34), + 'SpringGreen3', + 35, +) +dark_cyan = Colors.register(RGB(0, 175, 135), HSL(66, 100, 34), 'DarkCyan', 36) +light_sea_green = Colors.register( + RGB(0, 175, 175), + HSL(180, 100, 34), + 'LightSeaGreen', + 37, +) +deep_sky_blue2 = Colors.register( + RGB(0, 175, 215), + HSL(91, 100, 42), + 'DeepSkyBlue2', + 38, +) +deep_sky_blue1 = Colors.register( + RGB(0, 175, 255), + HSL(98, 100, 50), + 'DeepSkyBlue1', + 39, +) +green3 = Colors.register(RGB(0, 215, 0), HSL(120, 100, 42), 'Green3', 40) +spring_green3 = Colors.register( + RGB(0, 215, 95), + HSL(46, 100, 42), + 'SpringGreen3', + 41, +) +spring_green2 = Colors.register( + RGB(0, 215, 135), + HSL(57, 100, 42), + 'SpringGreen2', + 42, +) +cyan3 = Colors.register(RGB(0, 215, 175), HSL(68, 100, 42), 'Cyan3', 43) +dark_turquoise = Colors.register( + RGB(0, 215, 215), + HSL(180, 100, 42), + 'DarkTurquoise', + 44, +) +turquoise2 = Colors.register( + RGB(0, 215, 255), + HSL(89, 100, 50), + 'Turquoise2', + 45, +) +green1 = Colors.register(RGB(0, 255, 0), HSL(120, 100, 50), 'Green1', 46) +spring_green2 = Colors.register( + RGB(0, 255, 95), + HSL(42, 100, 50), + 'SpringGreen2', + 47, +) +spring_green1 = Colors.register( + RGB(0, 255, 135), + HSL(51, 100, 50), + 'SpringGreen1', + 48, +) +medium_spring_green = Colors.register( + RGB(0, 255, 175), + HSL(61, 100, 50), + 'MediumSpringGreen', + 49, +) +cyan2 = Colors.register(RGB(0, 255, 215), HSL(70, 100, 50), 'Cyan2', 50) +cyan1 = Colors.register(RGB(0, 255, 255), HSL(180, 100, 50), 'Cyan1', 51) +dark_red = Colors.register(RGB(95, 0, 0), HSL(0, 100, 18), 'DarkRed', 52) +deep_pink4 = Colors.register( + RGB(95, 0, 95), + HSL(300, 100, 18), + 'DeepPink4', + 53, +) +purple4 = Colors.register(RGB(95, 0, 135), HSL(82, 100, 26), 'Purple4', 54) +purple4 = Colors.register(RGB(95, 0, 175), HSL(72, 100, 34), 'Purple4', 55) +purple3 = Colors.register(RGB(95, 0, 215), HSL(66, 100, 42), 'Purple3', 56) +blue_violet = Colors.register( + RGB(95, 0, 255), + HSL(62, 100, 50), + 'BlueViolet', + 57, +) +orange4 = Colors.register(RGB(95, 95, 0), HSL(60, 100, 18), 'Orange4', 58) +grey37 = Colors.register(RGB(95, 95, 95), HSL(0, 0, 37), 'Grey37', 59) +medium_purple4 = Colors.register( + RGB(95, 95, 135), + HSL(240, 17, 45), + 'MediumPurple4', + 60, +) +slate_blue3 = Colors.register( + RGB(95, 95, 175), + HSL(240, 33, 52), + 'SlateBlue3', + 61, +) +slate_blue3 = Colors.register( + RGB(95, 95, 215), + HSL(240, 60, 60), + 'SlateBlue3', + 62, +) +royal_blue1 = Colors.register( + RGB(95, 95, 255), + HSL(240, 100, 68), + 'RoyalBlue1', + 63, +) +chartreuse4 = Colors.register( + RGB(95, 135, 0), + HSL(7, 100, 26), + 'Chartreuse4', + 64, +) +dark_sea_green4 = Colors.register( + RGB(95, 135, 95), + HSL(120, 17, 45), + 'DarkSeaGreen4', + 65, +) +pale_turquoise4 = Colors.register( + RGB(95, 135, 135), + HSL(180, 17, 45), + 'PaleTurquoise4', + 66, +) +steel_blue = Colors.register( + RGB(95, 135, 175), + HSL(210, 33, 52), + 'SteelBlue', + 67, +) +steel_blue3 = Colors.register( + RGB(95, 135, 215), + HSL(220, 60, 60), + 'SteelBlue3', + 68, +) +cornflower_blue = Colors.register( + RGB(95, 135, 255), + HSL(225, 100, 68), + 'CornflowerBlue', + 69, +) +chartreuse3 = Colors.register( + RGB(95, 175, 0), + HSL(7, 100, 34), + 'Chartreuse3', + 70, +) +dark_sea_green4 = Colors.register( + RGB(95, 175, 95), + HSL(120, 33, 52), + 'DarkSeaGreen4', + 71, +) +cadet_blue = Colors.register( + RGB(95, 175, 135), + HSL(150, 33, 52), + 'CadetBlue', + 72, +) +cadet_blue = Colors.register( + RGB(95, 175, 175), + HSL(180, 33, 52), + 'CadetBlue', + 73, +) +sky_blue3 = Colors.register( + RGB(95, 175, 215), + HSL(200, 60, 60), + 'SkyBlue3', + 74, +) +steel_blue1 = Colors.register( + RGB(95, 175, 255), + HSL(210, 100, 68), + 'SteelBlue1', + 75, +) +chartreuse3 = Colors.register( + RGB(95, 215, 0), + HSL(3, 100, 42), + 'Chartreuse3', + 76, +) +pale_green3 = Colors.register( + RGB(95, 215, 95), + HSL(120, 60, 60), + 'PaleGreen3', + 77, +) +sea_green3 = Colors.register( + RGB(95, 215, 135), + HSL(140, 60, 60), + 'SeaGreen3', + 78, +) +aquamarine3 = Colors.register( + RGB(95, 215, 175), + HSL(160, 60, 60), + 'Aquamarine3', + 79, +) +medium_turquoise = Colors.register( + RGB(95, 215, 215), + HSL(180, 60, 60), + 'MediumTurquoise', + 80, +) +steel_blue1 = Colors.register( + RGB(95, 215, 255), + HSL(195, 100, 68), + 'SteelBlue1', + 81, +) +chartreuse2 = Colors.register( + RGB(95, 255, 0), + HSL(7, 100, 50), + 'Chartreuse2', + 82, +) +sea_green2 = Colors.register( + RGB(95, 255, 95), + HSL(120, 100, 68), + 'SeaGreen2', + 83, +) +sea_green1 = Colors.register( + RGB(95, 255, 135), + HSL(135, 100, 68), + 'SeaGreen1', + 84, +) +sea_green1 = Colors.register( + RGB(95, 255, 175), + HSL(150, 100, 68), + 'SeaGreen1', + 85, +) +aquamarine1 = Colors.register( + RGB(95, 255, 215), + HSL(165, 100, 68), + 'Aquamarine1', + 86, +) +dark_slate_gray2 = Colors.register( + RGB(95, 255, 255), + HSL(180, 100, 68), + 'DarkSlateGray2', + 87, +) +dark_red = Colors.register(RGB(135, 0, 0), HSL(0, 100, 26), 'DarkRed', 88) +deep_pink4 = Colors.register( + RGB(135, 0, 95), + HSL(17, 100, 26), + 'DeepPink4', + 89, +) +dark_magenta = Colors.register( + RGB(135, 0, 135), + HSL(300, 100, 26), + 'DarkMagenta', + 90, +) +dark_magenta = Colors.register( + RGB(135, 0, 175), + HSL(86, 100, 34), + 'DarkMagenta', + 91, +) +dark_violet = Colors.register( + RGB(135, 0, 215), + HSL(77, 100, 42), + 'DarkViolet', + 92, +) +purple = Colors.register(RGB(135, 0, 255), HSL(71, 100, 50), 'Purple', 93) +orange4 = Colors.register(RGB(135, 95, 0), HSL(2, 100, 26), 'Orange4', 94) +light_pink4 = Colors.register( + RGB(135, 95, 95), + HSL(0, 17, 45), + 'LightPink4', + 95, +) +plum4 = Colors.register(RGB(135, 95, 135), HSL(300, 17, 45), 'Plum4', 96) +medium_purple3 = Colors.register( + RGB(135, 95, 175), + HSL(270, 33, 52), + 'MediumPurple3', + 97, +) +medium_purple3 = Colors.register( + RGB(135, 95, 215), + HSL(260, 60, 60), + 'MediumPurple3', + 98, +) +slate_blue1 = Colors.register( + RGB(135, 95, 255), + HSL(255, 100, 68), + 'SlateBlue1', + 99, +) +yellow4 = Colors.register(RGB(135, 135, 0), HSL(60, 100, 26), 'Yellow4', 100) +wheat4 = Colors.register(RGB(135, 135, 95), HSL(60, 17, 45), 'Wheat4', 101) +grey53 = Colors.register(RGB(135, 135, 135), HSL(0, 0, 52), 'Grey53', 102) +light_slate_grey = Colors.register( + RGB(135, 135, 175), + HSL(240, 20, 60), + 'LightSlateGrey', + 103, +) +medium_purple = Colors.register( + RGB(135, 135, 215), + HSL(240, 50, 68), + 'MediumPurple', + 104, +) +light_slate_blue = Colors.register( + RGB(135, 135, 255), + HSL(240, 100, 76), + 'LightSlateBlue', + 105, +) +yellow4 = Colors.register(RGB(135, 175, 0), HSL(3, 100, 34), 'Yellow4', 106) +dark_olive_green3 = Colors.register( + RGB(135, 175, 95), + HSL(90, 33, 52), + 'DarkOliveGreen3', + 107, +) +dark_sea_green = Colors.register( + RGB(135, 175, 135), + HSL(120, 20, 60), + 'DarkSeaGreen', + 108, +) +light_sky_blue3 = Colors.register( + RGB(135, 175, 175), + HSL(180, 20, 60), + 'LightSkyBlue3', + 109, +) +light_sky_blue3 = Colors.register( + RGB(135, 175, 215), + HSL(210, 50, 68), + 'LightSkyBlue3', + 110, +) +sky_blue2 = Colors.register( + RGB(135, 175, 255), + HSL(220, 100, 76), + 'SkyBlue2', + 111, +) +chartreuse2 = Colors.register( + RGB(135, 215, 0), + HSL(2, 100, 42), + 'Chartreuse2', + 112, +) +dark_olive_green3 = Colors.register( + RGB(135, 215, 95), + HSL(100, 60, 60), + 'DarkOliveGreen3', + 113, +) +pale_green3 = Colors.register( + RGB(135, 215, 135), + HSL(120, 50, 68), + 'PaleGreen3', + 114, +) +dark_sea_green3 = Colors.register( + RGB(135, 215, 175), + HSL(150, 50, 68), + 'DarkSeaGreen3', + 115, +) +dark_slate_gray3 = Colors.register( + RGB(135, 215, 215), + HSL(180, 50, 68), + 'DarkSlateGray3', + 116, +) +sky_blue1 = Colors.register( + RGB(135, 215, 255), + HSL(200, 100, 76), + 'SkyBlue1', + 117, +) +chartreuse1 = Colors.register( + RGB(135, 255, 0), + HSL(8, 100, 50), + 'Chartreuse1', + 118, +) +light_green = Colors.register( + RGB(135, 255, 95), + HSL(105, 100, 68), + 'LightGreen', + 119, +) +light_green = Colors.register( + RGB(135, 255, 135), + HSL(120, 100, 76), + 'LightGreen', + 120, +) +pale_green1 = Colors.register( + RGB(135, 255, 175), + HSL(140, 100, 76), + 'PaleGreen1', + 121, +) +aquamarine1 = Colors.register( + RGB(135, 255, 215), + HSL(160, 100, 76), + 'Aquamarine1', + 122, +) +dark_slate_gray1 = Colors.register( + RGB(135, 255, 255), + HSL(180, 100, 76), + 'DarkSlateGray1', + 123, +) +red3 = Colors.register(RGB(175, 0, 0), HSL(0, 100, 34), 'Red3', 124) +deep_pink4 = Colors.register( + RGB(175, 0, 95), + HSL(27, 100, 34), + 'DeepPink4', + 125, +) +medium_violet_red = Colors.register( + RGB(175, 0, 135), + HSL(13, 100, 34), + 'MediumVioletRed', + 126, +) +magenta3 = Colors.register( + RGB(175, 0, 175), + HSL(300, 100, 34), + 'Magenta3', + 127, +) +dark_violet = Colors.register( + RGB(175, 0, 215), + HSL(88, 100, 42), + 'DarkViolet', + 128, +) +purple = Colors.register(RGB(175, 0, 255), HSL(81, 100, 50), 'Purple', 129) +dark_orange3 = Colors.register( + RGB(175, 95, 0), + HSL(2, 100, 34), + 'DarkOrange3', + 130, +) +indian_red = Colors.register( + RGB(175, 95, 95), + HSL(0, 33, 52), + 'IndianRed', + 131, +) +hot_pink3 = Colors.register( + RGB(175, 95, 135), + HSL(330, 33, 52), + 'HotPink3', + 132, +) +medium_orchid3 = Colors.register( + RGB(175, 95, 175), + HSL(300, 33, 52), + 'MediumOrchid3', + 133, +) +medium_orchid = Colors.register( + RGB(175, 95, 215), + HSL(280, 60, 60), + 'MediumOrchid', + 134, +) +medium_purple2 = Colors.register( + RGB(175, 95, 255), + HSL(270, 100, 68), + 'MediumPurple2', + 135, +) +dark_goldenrod = Colors.register( + RGB(175, 135, 0), + HSL(6, 100, 34), + 'DarkGoldenrod', + 136, +) +light_salmon3 = Colors.register( + RGB(175, 135, 95), + HSL(30, 33, 52), + 'LightSalmon3', + 137, +) +rosy_brown = Colors.register( + RGB(175, 135, 135), + HSL(0, 20, 60), + 'RosyBrown', + 138, +) +grey63 = Colors.register(RGB(175, 135, 175), HSL(300, 20, 60), 'Grey63', 139) +medium_purple2 = Colors.register( + RGB(175, 135, 215), + HSL(270, 50, 68), + 'MediumPurple2', + 140, +) +medium_purple1 = Colors.register( + RGB(175, 135, 255), + HSL(260, 100, 76), + 'MediumPurple1', + 141, +) +gold3 = Colors.register(RGB(175, 175, 0), HSL(60, 100, 34), 'Gold3', 142) +dark_khaki = Colors.register( + RGB(175, 175, 95), + HSL(60, 33, 52), + 'DarkKhaki', + 143, +) +navajo_white3 = Colors.register( + RGB(175, 175, 135), + HSL(60, 20, 60), + 'NavajoWhite3', + 144, +) +grey69 = Colors.register(RGB(175, 175, 175), HSL(0, 0, 68), 'Grey69', 145) +light_steel_blue3 = Colors.register( + RGB(175, 175, 215), + HSL(240, 33, 76), + 'LightSteelBlue3', + 146, +) +light_steel_blue = Colors.register( + RGB(175, 175, 255), + HSL(240, 100, 84), + 'LightSteelBlue', + 147, +) +yellow3 = Colors.register(RGB(175, 215, 0), HSL(1, 100, 42), 'Yellow3', 148) +dark_olive_green3 = Colors.register( + RGB(175, 215, 95), + HSL(80, 60, 60), + 'DarkOliveGreen3', + 149, +) +dark_sea_green3 = Colors.register( + RGB(175, 215, 135), + HSL(90, 50, 68), + 'DarkSeaGreen3', + 150, +) +dark_sea_green2 = Colors.register( + RGB(175, 215, 175), + HSL(120, 33, 76), + 'DarkSeaGreen2', + 151, +) +light_cyan3 = Colors.register( + RGB(175, 215, 215), + HSL(180, 33, 76), + 'LightCyan3', + 152, +) +light_sky_blue1 = Colors.register( + RGB(175, 215, 255), + HSL(210, 100, 84), + 'LightSkyBlue1', + 153, +) +green_yellow = Colors.register( + RGB(175, 255, 0), + HSL(8, 100, 50), + 'GreenYellow', + 154, +) +dark_olive_green2 = Colors.register( + RGB(175, 255, 95), + HSL(90, 100, 68), + 'DarkOliveGreen2', + 155, +) +pale_green1 = Colors.register( + RGB(175, 255, 135), + HSL(100, 100, 76), + 'PaleGreen1', + 156, +) +dark_sea_green2 = Colors.register( + RGB(175, 255, 175), + HSL(120, 100, 84), + 'DarkSeaGreen2', + 157, +) +dark_sea_green1 = Colors.register( + RGB(175, 255, 215), + HSL(150, 100, 84), + 'DarkSeaGreen1', + 158, +) +pale_turquoise1 = Colors.register( + RGB(175, 255, 255), + HSL(180, 100, 84), + 'PaleTurquoise1', + 159, +) +red3 = Colors.register(RGB(215, 0, 0), HSL(0, 100, 42), 'Red3', 160) +deep_pink3 = Colors.register( + RGB(215, 0, 95), + HSL(33, 100, 42), + 'DeepPink3', + 161, +) +deep_pink3 = Colors.register( + RGB(215, 0, 135), + HSL(22, 100, 42), + 'DeepPink3', + 162, +) +magenta3 = Colors.register(RGB(215, 0, 175), HSL(11, 100, 42), 'Magenta3', 163) +magenta3 = Colors.register( + RGB(215, 0, 215), + HSL(300, 100, 42), + 'Magenta3', + 164, +) +magenta2 = Colors.register(RGB(215, 0, 255), HSL(90, 100, 50), 'Magenta2', 165) +dark_orange3 = Colors.register( + RGB(215, 95, 0), + HSL(6, 100, 42), + 'DarkOrange3', + 166, +) +indian_red = Colors.register( + RGB(215, 95, 95), + HSL(0, 60, 60), + 'IndianRed', + 167, +) +hot_pink3 = Colors.register( + RGB(215, 95, 135), + HSL(340, 60, 60), + 'HotPink3', + 168, +) +hot_pink2 = Colors.register( + RGB(215, 95, 175), + HSL(320, 60, 60), + 'HotPink2', + 169, +) +orchid = Colors.register(RGB(215, 95, 215), HSL(300, 60, 60), 'Orchid', 170) +medium_orchid1 = Colors.register( + RGB(215, 95, 255), + HSL(285, 100, 68), + 'MediumOrchid1', + 171, +) +orange3 = Colors.register(RGB(215, 135, 0), HSL(7, 100, 42), 'Orange3', 172) +light_salmon3 = Colors.register( + RGB(215, 135, 95), + HSL(20, 60, 60), + 'LightSalmon3', + 173, +) +light_pink3 = Colors.register( + RGB(215, 135, 135), + HSL(0, 50, 68), + 'LightPink3', + 174, +) +pink3 = Colors.register(RGB(215, 135, 175), HSL(330, 50, 68), 'Pink3', 175) +plum3 = Colors.register(RGB(215, 135, 215), HSL(300, 50, 68), 'Plum3', 176) +violet = Colors.register(RGB(215, 135, 255), HSL(280, 100, 76), 'Violet', 177) +gold3 = Colors.register(RGB(215, 175, 0), HSL(8, 100, 42), 'Gold3', 178) +light_goldenrod3 = Colors.register( + RGB(215, 175, 95), + HSL(40, 60, 60), + 'LightGoldenrod3', + 179, +) +tan = Colors.register(RGB(215, 175, 135), HSL(30, 50, 68), 'Tan', 180) +misty_rose3 = Colors.register( + RGB(215, 175, 175), + HSL(0, 33, 76), + 'MistyRose3', + 181, +) +thistle3 = Colors.register( + RGB(215, 175, 215), + HSL(300, 33, 76), + 'Thistle3', + 182, +) +plum2 = Colors.register(RGB(215, 175, 255), HSL(270, 100, 84), 'Plum2', 183) +yellow3 = Colors.register(RGB(215, 215, 0), HSL(60, 100, 42), 'Yellow3', 184) +khaki3 = Colors.register(RGB(215, 215, 95), HSL(60, 60, 60), 'Khaki3', 185) +light_goldenrod2 = Colors.register( + RGB(215, 215, 135), + HSL(60, 50, 68), + 'LightGoldenrod2', + 186, +) +light_yellow3 = Colors.register( + RGB(215, 215, 175), + HSL(60, 33, 76), + 'LightYellow3', + 187, +) +grey84 = Colors.register(RGB(215, 215, 215), HSL(0, 0, 84), 'Grey84', 188) +light_steel_blue1 = Colors.register( + RGB(215, 215, 255), + HSL(240, 100, 92), + 'LightSteelBlue1', + 189, +) +yellow2 = Colors.register(RGB(215, 255, 0), HSL(9, 100, 50), 'Yellow2', 190) +dark_olive_green1 = Colors.register( + RGB(215, 255, 95), + HSL(75, 100, 68), + 'DarkOliveGreen1', + 191, +) +dark_olive_green1 = Colors.register( + RGB(215, 255, 135), + HSL(80, 100, 76), + 'DarkOliveGreen1', + 192, +) +dark_sea_green1 = Colors.register( + RGB(215, 255, 175), + HSL(90, 100, 84), + 'DarkSeaGreen1', + 193, +) +honeydew2 = Colors.register( + RGB(215, 255, 215), + HSL(120, 100, 92), + 'Honeydew2', + 194, +) +light_cyan1 = Colors.register( + RGB(215, 255, 255), + HSL(180, 100, 92), + 'LightCyan1', + 195, +) +red1 = Colors.register(RGB(255, 0, 0), HSL(0, 100, 50), 'Red1', 196) +deep_pink2 = Colors.register( + RGB(255, 0, 95), + HSL(37, 100, 50), + 'DeepPink2', + 197, +) +deep_pink1 = Colors.register( + RGB(255, 0, 135), + HSL(28, 100, 50), + 'DeepPink1', + 198, +) +deep_pink1 = Colors.register( + RGB(255, 0, 175), + HSL(18, 100, 50), + 'DeepPink1', + 199, +) +magenta2 = Colors.register(RGB(255, 0, 215), HSL(9, 100, 50), 'Magenta2', 200) +magenta1 = Colors.register( + RGB(255, 0, 255), + HSL(300, 100, 50), + 'Magenta1', + 201, +) +orange_red1 = Colors.register( + RGB(255, 95, 0), + HSL(2, 100, 50), + 'OrangeRed1', + 202, +) +indian_red1 = Colors.register( + RGB(255, 95, 95), + HSL(0, 100, 68), + 'IndianRed1', + 203, +) +indian_red1 = Colors.register( + RGB(255, 95, 135), + HSL(345, 100, 68), + 'IndianRed1', + 204, +) +hot_pink = Colors.register( + RGB(255, 95, 175), + HSL(330, 100, 68), + 'HotPink', + 205, +) +hot_pink = Colors.register( + RGB(255, 95, 215), + HSL(315, 100, 68), + 'HotPink', + 206, +) +medium_orchid1 = Colors.register( + RGB(255, 95, 255), + HSL(300, 100, 68), + 'MediumOrchid1', + 207, +) +dark_orange = Colors.register( + RGB(255, 135, 0), + HSL(1, 100, 50), + 'DarkOrange', + 208, +) +salmon1 = Colors.register(RGB(255, 135, 95), HSL(15, 100, 68), 'Salmon1', 209) +light_coral = Colors.register( + RGB(255, 135, 135), + HSL(0, 100, 76), + 'LightCoral', + 210, +) +pale_violet_red1 = Colors.register( + RGB(255, 135, 175), + HSL(340, 100, 76), + 'PaleVioletRed1', + 211, +) +orchid2 = Colors.register( + RGB(255, 135, 215), + HSL(320, 100, 76), + 'Orchid2', + 212, +) +orchid1 = Colors.register( + RGB(255, 135, 255), + HSL(300, 100, 76), + 'Orchid1', + 213, +) +orange1 = Colors.register(RGB(255, 175, 0), HSL(1, 100, 50), 'Orange1', 214) +sandy_brown = Colors.register( + RGB(255, 175, 95), + HSL(30, 100, 68), + 'SandyBrown', + 215, +) +light_salmon1 = Colors.register( + RGB(255, 175, 135), + HSL(20, 100, 76), + 'LightSalmon1', + 216, +) +light_pink1 = Colors.register( + RGB(255, 175, 175), + HSL(0, 100, 84), + 'LightPink1', + 217, +) +pink1 = Colors.register(RGB(255, 175, 215), HSL(330, 100, 84), 'Pink1', 218) +plum1 = Colors.register(RGB(255, 175, 255), HSL(300, 100, 84), 'Plum1', 219) +gold1 = Colors.register(RGB(255, 215, 0), HSL(0, 100, 50), 'Gold1', 220) +light_goldenrod2 = Colors.register( + RGB(255, 215, 95), + HSL(45, 100, 68), + 'LightGoldenrod2', + 221, +) +light_goldenrod2 = Colors.register( + RGB(255, 215, 135), + HSL(40, 100, 76), + 'LightGoldenrod2', + 222, +) +navajo_white1 = Colors.register( + RGB(255, 215, 175), + HSL(30, 100, 84), + 'NavajoWhite1', + 223, +) +misty_rose1 = Colors.register( + RGB(255, 215, 215), + HSL(0, 100, 92), + 'MistyRose1', + 224, +) +thistle1 = Colors.register( + RGB(255, 215, 255), + HSL(300, 100, 92), + 'Thistle1', + 225, +) +yellow1 = Colors.register(RGB(255, 255, 0), HSL(60, 100, 50), 'Yellow1', 226) +light_goldenrod1 = Colors.register( + RGB(255, 255, 95), + HSL(60, 100, 68), + 'LightGoldenrod1', + 227, +) +khaki1 = Colors.register(RGB(255, 255, 135), HSL(60, 100, 76), 'Khaki1', 228) +wheat1 = Colors.register(RGB(255, 255, 175), HSL(60, 100, 84), 'Wheat1', 229) +cornsilk1 = Colors.register( + RGB(255, 255, 215), + HSL(60, 100, 92), + 'Cornsilk1', + 230, +) +grey100 = Colors.register(RGB(255, 255, 255), HSL(0, 0, 100), 'Grey100', 231) +grey3 = Colors.register(RGB(8, 8, 8), HSL(0, 0, 3), 'Grey3', 232) +grey7 = Colors.register(RGB(18, 18, 18), HSL(0, 0, 7), 'Grey7', 233) +grey11 = Colors.register(RGB(28, 28, 28), HSL(0, 0, 10), 'Grey11', 234) +grey15 = Colors.register(RGB(38, 38, 38), HSL(0, 0, 14), 'Grey15', 235) +grey19 = Colors.register(RGB(48, 48, 48), HSL(0, 0, 18), 'Grey19', 236) +grey23 = Colors.register(RGB(58, 58, 58), HSL(0, 0, 22), 'Grey23', 237) +grey27 = Colors.register(RGB(68, 68, 68), HSL(0, 0, 26), 'Grey27', 238) +grey30 = Colors.register(RGB(78, 78, 78), HSL(0, 0, 30), 'Grey30', 239) +grey35 = Colors.register(RGB(88, 88, 88), HSL(0, 0, 34), 'Grey35', 240) +grey39 = Colors.register(RGB(98, 98, 98), HSL(0, 0, 37), 'Grey39', 241) +grey42 = Colors.register(RGB(108, 108, 108), HSL(0, 0, 40), 'Grey42', 242) +grey46 = Colors.register(RGB(118, 118, 118), HSL(0, 0, 46), 'Grey46', 243) +grey50 = Colors.register(RGB(128, 128, 128), HSL(0, 0, 50), 'Grey50', 244) +grey54 = Colors.register(RGB(138, 138, 138), HSL(0, 0, 54), 'Grey54', 245) +grey58 = Colors.register(RGB(148, 148, 148), HSL(0, 0, 58), 'Grey58', 246) +grey62 = Colors.register(RGB(158, 158, 158), HSL(0, 0, 61), 'Grey62', 247) +grey66 = Colors.register(RGB(168, 168, 168), HSL(0, 0, 65), 'Grey66', 248) +grey70 = Colors.register(RGB(178, 178, 178), HSL(0, 0, 69), 'Grey70', 249) +grey74 = Colors.register(RGB(188, 188, 188), HSL(0, 0, 73), 'Grey74', 250) +grey78 = Colors.register(RGB(198, 198, 198), HSL(0, 0, 77), 'Grey78', 251) +grey82 = Colors.register(RGB(208, 208, 208), HSL(0, 0, 81), 'Grey82', 252) +grey85 = Colors.register(RGB(218, 218, 218), HSL(0, 0, 85), 'Grey85', 253) +grey89 = Colors.register(RGB(228, 228, 228), HSL(0, 0, 89), 'Grey89', 254) +grey93 = Colors.register(RGB(238, 238, 238), HSL(0, 0, 93), 'Grey93', 255) + +dark_gradient: ColorGradient = ColorGradient( + red1, + orange_red1, + dark_orange, + orange1, + yellow1, + yellow2, + green_yellow, + green1, +) +light_gradient: ColorGradient = ColorGradient( + red1, + orange_red1, + dark_orange, + orange1, + gold3, + dark_olive_green3, + yellow4, + green3, +) +bg_gradient: ColorGradient = ColorGradient(black) + +# Check if the background is light or dark. This is by no means a foolproof +# method, but there is no reliable way to detect this. +_colorfgbg: list[str] = os.environ.get('COLORFGBG', '15;0').split(';') +if _colorfgbg[-1] == str(white.xterm): # pragma: no cover + # Light background + gradient: ColorGradient = light_gradient + primary = black +else: + # Default, expect a dark background + gradient: ColorGradient = dark_gradient + primary = white diff --git a/progressbar/terminal/os_specific/__init__.py b/progressbar/terminal/os_specific/__init__.py new file mode 100644 index 00000000..833feeba --- /dev/null +++ b/progressbar/terminal/os_specific/__init__.py @@ -0,0 +1,27 @@ +import os + +if os.name == 'nt': + from .windows import ( + get_console_mode as _get_console_mode, + getch as _getch, + reset_console_mode as _reset_console_mode, + set_console_mode as _set_console_mode, + ) + +else: + from .posix import getch as _getch + + def _reset_console_mode() -> None: + pass + + def _set_console_mode() -> bool: + return False + + def _get_console_mode() -> int: + return 0 + + +getch = _getch +reset_console_mode = _reset_console_mode +set_console_mode = _set_console_mode +get_console_mode = _get_console_mode diff --git a/progressbar/terminal/os_specific/posix.py b/progressbar/terminal/os_specific/posix.py new file mode 100644 index 00000000..34819983 --- /dev/null +++ b/progressbar/terminal/os_specific/posix.py @@ -0,0 +1,15 @@ +import sys +import termios +import tty + + +def getch() -> str: + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) # type: ignore + try: + tty.setraw(sys.stdin.fileno()) # type: ignore + ch = sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) # type: ignore + + return ch diff --git a/progressbar/terminal/os_specific/windows.py b/progressbar/terminal/os_specific/windows.py new file mode 100644 index 00000000..8d1f3f4b --- /dev/null +++ b/progressbar/terminal/os_specific/windows.py @@ -0,0 +1,174 @@ +# ruff: noqa: N801 +""" +Windows specific code for the terminal. + +Note that the naming convention here is non-pythonic because we are +matching the Windows API naming. +""" + +from __future__ import annotations + +import ctypes +import enum +from ctypes.wintypes import ( + BOOL as _BOOL, + CHAR as _CHAR, + DWORD as _DWORD, + HANDLE as _HANDLE, + SHORT as _SHORT, + UINT as _UINT, + WCHAR as _WCHAR, + WORD as _WORD, +) + +_kernel32 = ctypes.windll.Kernel32 # type: ignore + +_STD_INPUT_HANDLE = _DWORD(-10) +_STD_OUTPUT_HANDLE = _DWORD(-11) + + +class WindowsConsoleModeFlags(enum.IntFlag): + ENABLE_ECHO_INPUT = 0x0004 + ENABLE_EXTENDED_FLAGS = 0x0080 + ENABLE_INSERT_MODE = 0x0020 + ENABLE_LINE_INPUT = 0x0002 + ENABLE_MOUSE_INPUT = 0x0010 + ENABLE_PROCESSED_INPUT = 0x0001 + ENABLE_QUICK_EDIT_MODE = 0x0040 + ENABLE_WINDOW_INPUT = 0x0008 + ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 + + ENABLE_PROCESSED_OUTPUT = 0x0001 + ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + DISABLE_NEWLINE_AUTO_RETURN = 0x0008 + ENABLE_LVB_GRID_WORLDWIDE = 0x0010 + + def __str__(self) -> str: + return f'{self.name} (0x{self.value:04X})' + + +_GetConsoleMode = _kernel32.GetConsoleMode +_GetConsoleMode.restype = _BOOL + +_SetConsoleMode = _kernel32.SetConsoleMode +_SetConsoleMode.restype = _BOOL + +_GetStdHandle = _kernel32.GetStdHandle +_GetStdHandle.restype = _HANDLE + +_ReadConsoleInput = _kernel32.ReadConsoleInputA +_ReadConsoleInput.restype = _BOOL + +_h_console_input = _GetStdHandle(_STD_INPUT_HANDLE) +_input_mode = _DWORD() +_GetConsoleMode(_HANDLE(_h_console_input), ctypes.byref(_input_mode)) + +_h_console_output = _GetStdHandle(_STD_OUTPUT_HANDLE) +_output_mode = _DWORD() +_GetConsoleMode(_HANDLE(_h_console_output), ctypes.byref(_output_mode)) + + +class _COORD(ctypes.Structure): + _fields_ = (('X', _SHORT), ('Y', _SHORT)) + + +class _FOCUS_EVENT_RECORD(ctypes.Structure): + _fields_ = (('bSetFocus', _BOOL),) + + +class _KEY_EVENT_RECORD(ctypes.Structure): + class _uchar(ctypes.Union): + _fields_ = (('UnicodeChar', _WCHAR), ('AsciiChar', _CHAR)) + + _fields_ = ( + ('bKeyDown', _BOOL), + ('wRepeatCount', _WORD), + ('wVirtualKeyCode', _WORD), + ('wVirtualScanCode', _WORD), + ('uChar', _uchar), + ('dwControlKeyState', _DWORD), + ) + + +class _MENU_EVENT_RECORD(ctypes.Structure): + _fields_ = (('dwCommandId', _UINT),) + + +class _MOUSE_EVENT_RECORD(ctypes.Structure): + _fields_ = ( + ('dwMousePosition', _COORD), + ('dwButtonState', _DWORD), + ('dwControlKeyState', _DWORD), + ('dwEventFlags', _DWORD), + ) + + +class _WINDOW_BUFFER_SIZE_RECORD(ctypes.Structure): + _fields_ = (('dwSize', _COORD),) + + +class _INPUT_RECORD(ctypes.Structure): + class _Event(ctypes.Union): + _fields_ = ( + ('KeyEvent', _KEY_EVENT_RECORD), + ('MouseEvent', _MOUSE_EVENT_RECORD), + ('WindowBufferSizeEvent', _WINDOW_BUFFER_SIZE_RECORD), + ('MenuEvent', _MENU_EVENT_RECORD), + ('FocusEvent', _FOCUS_EVENT_RECORD), + ) + + _fields_ = (('EventType', _WORD), ('Event', _Event)) + + +def reset_console_mode() -> None: + _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(_input_mode.value)) + _SetConsoleMode(_HANDLE(_h_console_output), _DWORD(_output_mode.value)) + + +def set_console_mode() -> bool: + mode = ( + _input_mode.value + | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_INPUT + ) + _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(mode)) + + mode = ( + _output_mode.value + | WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_PROCESSING + ) + return bool(_SetConsoleMode(_HANDLE(_h_console_output), _DWORD(mode))) + + +def get_console_mode() -> int: + return _input_mode.value + + +def set_text_color(color) -> None: + _kernel32.SetConsoleTextAttribute(_h_console_output, color) + + +def print_color(text, color) -> None: + set_text_color(color) + print(text) # noqa: T201 + set_text_color(7) # Reset to default color, grey + + +def getch(): + lp_buffer = (_INPUT_RECORD * 2)() + n_length = _DWORD(2) + lp_number_of_events_read = _DWORD() + + _ReadConsoleInput( + _HANDLE(_h_console_input), + lp_buffer, + n_length, + ctypes.byref(lp_number_of_events_read), + ) + + char = lp_buffer[1].Event.KeyEvent.uChar.AsciiChar.decode('ascii') + if char == '\x00': + return None + + return char diff --git a/progressbar/terminal/stream.py b/progressbar/terminal/stream.py new file mode 100644 index 00000000..eb8de2a3 --- /dev/null +++ b/progressbar/terminal/stream.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import sys +import typing +from types import TracebackType +from typing import Iterable, Iterator + +from progressbar import base + + +class TextIOOutputWrapper(base.TextIO): # pragma: no cover + def __init__(self, stream: base.TextIO) -> None: + self.stream = stream + + def close(self) -> None: + self.stream.close() + + def fileno(self) -> int: + return self.stream.fileno() + + def flush(self) -> None: + pass + + def isatty(self) -> bool: + return self.stream.isatty() + + def read(self, __n: int = -1) -> str: + return self.stream.read(__n) + + def readable(self) -> bool: + return self.stream.readable() + + def readline(self, __limit: int = -1) -> str: + return self.stream.readline(__limit) + + def readlines(self, __hint: int = -1) -> list[str]: + return self.stream.readlines(__hint) + + def seek(self, __offset: int, __whence: int = 0) -> int: + return self.stream.seek(__offset, __whence) + + def seekable(self) -> bool: + return self.stream.seekable() + + def tell(self) -> int: + return self.stream.tell() + + def truncate(self, __size: int | None = None) -> int: + return self.stream.truncate(__size) + + def writable(self) -> bool: + return self.stream.writable() + + def writelines(self, __lines: Iterable[str]) -> None: + return self.stream.writelines(__lines) + + def __next__(self) -> str: + return self.stream.__next__() + + def __iter__(self) -> Iterator[str]: + return self.stream.__iter__() + + def __exit__( + self, + __t: type[BaseException] | None, + __value: BaseException | None, + __traceback: TracebackType | None, + ) -> None: + return self.stream.__exit__(__t, __value, __traceback) + + def __enter__(self) -> base.TextIO: + return self.stream.__enter__() + + +class LineOffsetStreamWrapper(TextIOOutputWrapper): + UP = '\033[F' + DOWN = '\033[B' + + def __init__( + self, lines: int = 0, stream: typing.TextIO = sys.stderr + ) -> None: + self.lines = lines + super().__init__(stream) + + def write(self, data: str) -> int: + data = data.rstrip('\n') + # Move the cursor up + self.stream.write(self.UP * self.lines) + # Print a carriage return to reset the cursor position + self.stream.write('\r') + # Print the data without newlines so we don't change the position + self.stream.write(data) + # Move the cursor down + self.stream.write(self.DOWN * self.lines) + + self.flush() + return len(data) + + +class LastLineStream(TextIOOutputWrapper): + line: str = '' + + def seekable(self) -> bool: + return False + + def readable(self) -> bool: + return True + + def read(self, __n: int = -1) -> str: + if __n < 0: + return self.line + else: + return self.line[:__n] + + def readline(self, __limit: int = -1) -> str: + if __limit < 0: + return self.line + else: + return self.line[:__limit] + + def write(self, data: str) -> int: + self.line = data + return len(data) + + def truncate(self, __size: int | None = None) -> int: + if __size is None: + self.line = '' + else: + self.line = self.line[:__size] + + return len(self.line) + + def __iter__(self) -> typing.Generator[str, typing.Any, typing.Any]: + yield self.line + + def writelines(self, __lines: Iterable[str]) -> None: + line = '' + # Walk through the lines and take the last one + for line in __lines: # noqa: B007 + pass + + self.line = line diff --git a/progressbar/utils.py b/progressbar/utils.py new file mode 100644 index 00000000..6323ae8f --- /dev/null +++ b/progressbar/utils.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +import atexit +import contextlib +import datetime +import io +import logging +import os +import re +import sys +from types import TracebackType +from typing import Iterable, Iterator + +from python_utils import types +from python_utils.converters import scale_1024 +from python_utils.terminal import get_terminal_size +from python_utils.time import epoch, format_time, timedelta_to_seconds + +from progressbar import base, env, terminal + +if types.TYPE_CHECKING: + from .bar import ProgressBar, ProgressBarMixinBase + +# Make sure these are available for import +assert timedelta_to_seconds is not None +assert get_terminal_size is not None +assert format_time is not None +assert scale_1024 is not None +assert epoch is not None + +StringT = types.TypeVar('StringT', bound=types.StringTypes) + + +def deltas_to_seconds( + *deltas: None | datetime.timedelta | float, + default: types.Optional[types.Type[ValueError]] = ValueError, +) -> int | float | None: + """ + Convert timedeltas and seconds as int to seconds as float while coalescing. + + >>> deltas_to_seconds(datetime.timedelta(seconds=1, milliseconds=234)) + 1.234 + >>> deltas_to_seconds(123) + 123.0 + >>> deltas_to_seconds(1.234) + 1.234 + >>> deltas_to_seconds(None, 1.234) + 1.234 + >>> deltas_to_seconds(0, 1.234) + 0.0 + >>> deltas_to_seconds() + Traceback (most recent call last): + ... + ValueError: No valid deltas passed to `deltas_to_seconds` + >>> deltas_to_seconds(None) + Traceback (most recent call last): + ... + ValueError: No valid deltas passed to `deltas_to_seconds` + >>> deltas_to_seconds(default=0.0) + 0.0 + """ + for delta in deltas: + if delta is None: + continue + if isinstance(delta, datetime.timedelta): + return timedelta_to_seconds(delta) + elif not isinstance(delta, float): + return float(delta) + else: + return delta + + if default is ValueError: + raise ValueError('No valid deltas passed to `deltas_to_seconds`') + else: + # mypy doesn't understand the `default is ValueError` check + return default # type: ignore + + +def no_color(value: StringT) -> StringT: + """ + Return the `value` without ANSI escape codes. + + >>> no_color(b'\u001b[1234]abc') + b'abc' + >>> str(no_color('\u001b[1234]abc')) + 'abc' + >>> str(no_color('\u001b[1234]abc')) + 'abc' + >>> no_color(123) + Traceback (most recent call last): + ... + TypeError: `value` must be a string or bytes, got 123 + """ + if isinstance(value, bytes): + pattern: bytes = bytes(terminal.ESC, 'ascii') + b'\\[.*?[@-~]' + return re.sub(pattern, b'', value) # type: ignore + elif isinstance(value, str): + return re.sub('\x1b\\[.*?[@-~]', '', value) # type: ignore + else: + raise TypeError(f'`value` must be a string or bytes, got {value!r}') + + +def len_color(value: types.StringTypes) -> int: + """ + Return the length of `value` without ANSI escape codes. + + >>> len_color(b'\u001b[1234]abc') + 3 + >>> len_color('\u001b[1234]abc') + 3 + >>> len_color('\u001b[1234]abc') + 3 + """ + return len(no_color(value)) + + +class WrappingIO: + buffer: io.StringIO + target: base.IO + capturing: bool + listeners: set + needs_clear: bool = False + + def __init__( + self, + target: base.IO, + capturing: bool = False, + listeners: types.Optional[types.Set[ProgressBar]] = None, + ) -> None: + self.buffer = io.StringIO() + self.target = target + self.capturing = capturing + self.listeners = listeners or set() + self.needs_clear = False + + def write(self, value: str) -> int: + ret = 0 + if self.capturing: + ret += self.buffer.write(value) + if '\n' in value: # pragma: no branch + self.needs_clear = True + for listener in self.listeners: # pragma: no branch + listener.update() + else: + ret += self.target.write(value) + if '\n' in value: # pragma: no branch + self.flush_target() + + return ret + + def flush(self) -> None: + self.buffer.flush() + + def _flush(self) -> None: + if value := self.buffer.getvalue(): + self.flush() + self.target.write(value) + self.buffer.seek(0) + self.buffer.truncate(0) + self.needs_clear = False + + # when explicitly flushing, always flush the target as well + self.flush_target() + + def flush_target(self) -> None: # pragma: no cover + if not self.target.closed and getattr(self.target, 'flush', None): + self.target.flush() + + def __enter__(self) -> WrappingIO: + return self + + def fileno(self) -> int: + return self.target.fileno() + + def isatty(self) -> bool: + return self.target.isatty() + + def read(self, n: int = -1) -> str: + return self.target.read(n) + + def readable(self) -> bool: + return self.target.readable() + + def readline(self, limit: int = -1) -> str: + return self.target.readline(limit) + + def readlines(self, hint: int = -1) -> list[str]: + return self.target.readlines(hint) + + def seek(self, offset: int, whence: int = os.SEEK_SET) -> int: + return self.target.seek(offset, whence) + + def seekable(self) -> bool: + return self.target.seekable() + + def tell(self) -> int: + return self.target.tell() + + def truncate(self, size: types.Optional[int] = None) -> int: + return self.target.truncate(size) + + def writable(self) -> bool: + return self.target.writable() + + def writelines(self, lines: Iterable[str]) -> None: + return self.target.writelines(lines) + + def close(self) -> None: + self.flush() + self.target.close() + + def __next__(self) -> str: + return self.target.__next__() + + def __iter__(self) -> Iterator[str]: + return self.target.__iter__() + + def __exit__( + self, + __t: type[BaseException] | None, + __value: BaseException | None, + __traceback: TracebackType | None, + ) -> None: + self.close() + + +class StreamWrapper: + """Wrap stdout and stderr globally.""" + + stdout: base.TextIO | WrappingIO + stderr: base.TextIO | WrappingIO + original_excepthook: types.Callable[ + [ + types.Type[BaseException], + BaseException, + TracebackType | None, + ], + None, + ] + wrapped_stdout: int = 0 + wrapped_stderr: int = 0 + wrapped_excepthook: int = 0 + capturing: int = 0 + listeners: set + + def __init__(self) -> None: + self.stdout = self.original_stdout = sys.stdout + self.stderr = self.original_stderr = sys.stderr + self.original_excepthook = sys.excepthook + self.wrapped_stdout = 0 + self.wrapped_stderr = 0 + self.wrapped_excepthook = 0 + self.capturing = 0 + self.listeners = set() + + if env.env_flag('WRAP_STDOUT', default=False): # pragma: no cover + self.wrap_stdout() + + if env.env_flag('WRAP_STDERR', default=False): # pragma: no cover + self.wrap_stderr() + + def start_capturing(self, bar: ProgressBarMixinBase | None = None) -> None: + if bar: # pragma: no branch + self.listeners.add(bar) + + self.capturing += 1 + self.update_capturing() + + def stop_capturing(self, bar: ProgressBarMixinBase | None = None) -> None: + if bar: # pragma: no branch + with contextlib.suppress(KeyError): + self.listeners.remove(bar) + + self.capturing -= 1 + self.update_capturing() + + def update_capturing(self) -> None: # pragma: no cover + if isinstance(self.stdout, WrappingIO): + self.stdout.capturing = self.capturing > 0 + + if isinstance(self.stderr, WrappingIO): + self.stderr.capturing = self.capturing > 0 + + if self.capturing <= 0: + self.flush() + + def wrap(self, stdout: bool = False, stderr: bool = False) -> None: + if stdout: + self.wrap_stdout() + + if stderr: + self.wrap_stderr() + + def wrap_stdout(self) -> WrappingIO: + self.wrap_excepthook() + + if not self.wrapped_stdout: + self.stdout = sys.stdout = WrappingIO( # type: ignore + self.original_stdout, + listeners=self.listeners, + ) + self.wrapped_stdout += 1 + + return sys.stdout # type: ignore + + def wrap_stderr(self) -> WrappingIO: + self.wrap_excepthook() + + if not self.wrapped_stderr: + self.stderr = sys.stderr = WrappingIO( # type: ignore + self.original_stderr, + listeners=self.listeners, + ) + self.wrapped_stderr += 1 + + return sys.stderr # type: ignore + + def unwrap_excepthook(self) -> None: + if self.wrapped_excepthook: + self.wrapped_excepthook -= 1 + sys.excepthook = self.original_excepthook + + def wrap_excepthook(self) -> None: + if not self.wrapped_excepthook: + logger.debug('wrapping excepthook') + self.wrapped_excepthook += 1 + sys.excepthook = self.excepthook + + def unwrap(self, stdout: bool = False, stderr: bool = False) -> None: + if stdout: + self.unwrap_stdout() + + if stderr: + self.unwrap_stderr() + + def unwrap_stdout(self) -> None: + if self.wrapped_stdout > 1: + self.wrapped_stdout -= 1 + else: + sys.stdout = self.original_stdout + self.wrapped_stdout = 0 + + def unwrap_stderr(self) -> None: + if self.wrapped_stderr > 1: + self.wrapped_stderr -= 1 + else: + sys.stderr = self.original_stderr + self.wrapped_stderr = 0 + + def needs_clear(self) -> bool: # pragma: no cover + stdout_needs_clear = getattr(self.stdout, 'needs_clear', False) + stderr_needs_clear = getattr(self.stderr, 'needs_clear', False) + return stderr_needs_clear or stdout_needs_clear + + def flush(self) -> None: + if self.wrapped_stdout and isinstance(self.stdout, WrappingIO): + try: + self.stdout._flush() + except io.UnsupportedOperation: # pragma: no cover + self.wrapped_stdout = False + logger.warning( + 'Disabling stdout redirection, %r is not seekable', + sys.stdout, + ) + + if self.wrapped_stderr and isinstance(self.stderr, WrappingIO): + try: + self.stderr._flush() + except io.UnsupportedOperation: # pragma: no cover + self.wrapped_stderr = False + logger.warning( + 'Disabling stderr redirection, %r is not seekable', + sys.stderr, + ) + + def excepthook( + self, + exc_type: type[BaseException], + exc_value: BaseException, + exc_traceback: types.TracebackType | None, + ) -> None: + self.original_excepthook(exc_type, exc_value, exc_traceback) + self.flush() + + +class AttributeDict(dict): + """ + A dict that can be accessed with .attribute. + + >>> attrs = AttributeDict(spam=123) + + # Reading + + >>> attrs['spam'] + 123 + >>> attrs.spam + 123 + + # Read after update using attribute + + >>> attrs.spam = 456 + >>> attrs['spam'] + 456 + >>> attrs.spam + 456 + + # Read after update using dict access + + >>> attrs['spam'] = 123 + >>> attrs['spam'] + 123 + >>> attrs.spam + 123 + + # Read after update using dict access + + >>> del attrs.spam + >>> attrs['spam'] + Traceback (most recent call last): + ... + KeyError: 'spam' + >>> attrs.spam + Traceback (most recent call last): + ... + AttributeError: No such attribute: spam + >>> del attrs.spam + Traceback (most recent call last): + ... + AttributeError: No such attribute: spam + """ + + def __getattr__(self, name: str) -> int: + if name in self: + return self[name] + else: + raise AttributeError(f'No such attribute: {name}') + + def __setattr__(self, name: str, value: int) -> None: + self[name] = value + + def __delattr__(self, name: str) -> None: + if name in self: + del self[name] + else: + raise AttributeError(f'No such attribute: {name}') + + +logger: logging.Logger = logging.getLogger(__name__) +streams = StreamWrapper() +atexit.register(streams.flush) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 61156755..c8c3cdfc 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1,362 +1,1619 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# -# progressbar - Text progress bar library for Python. -# Copyright (c) 2005 Nilton Volpato -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - -'''Default ProgressBar widgets''' - -from __future__ import division, absolute_import, with_statement +from __future__ import annotations -import datetime -import math import abc +import contextlib +import datetime +import functools +import logging +import typing +# Ruff is being stupid and doesn't understand `ClassVar` if it comes from the +# `types` module +from typing import ClassVar -class AbstractWidget(object): - __metaclass__ = abc.ABCMeta +from python_utils import containers, converters, types +from . import algorithms, base, terminal, utils +from .terminal import colors -def format_updatable(updatable, pbar): - if hasattr(updatable, 'update'): - return updatable.update(pbar) - else: - return updatable +if types.TYPE_CHECKING: + from .bar import NumberT, ProgressBarMixinBase +logger = logging.getLogger(__name__) -class Widget(AbstractWidget): +MAX_DATE = datetime.date.max +MAX_TIME = datetime.time.max +MAX_DATETIME = datetime.datetime.max - '''The base class for all widgets +Data = types.Dict[str, types.Any] +FormatString = typing.Optional[str] - The ProgressBar will call the widget's update value when the widget should - be updated. The widget's size may change between calls, but the widget may - display incorrectly if the size changes drastically and repeatedly. +T = typing.TypeVar('T') - The boolean TIME_SENSITIVE informs the ProgressBar that it should be - updated more often because it is time sensitive. - ''' - TIME_SENSITIVE = False +def string_or_lambda(input_): + if isinstance(input_, str): - @abc.abstractmethod - def update(self, pbar): - '''Updates the widget. + def render_input(progress, data, width): + return input_ % data - pbar - a reference to the calling ProgressBar - ''' + return render_input + else: + return input_ -class WidgetHFill(Widget): +def create_wrapper(wrapper): + """Convert a wrapper tuple or format string to a format string. - '''The base class for all variable width widgets. + >>> create_wrapper('') - This widget is much like the \\hfill command in TeX, it will expand to - fill the line. You can use more than one in the same line, and they will - all have the same width, and together will fill the line. - ''' + >>> print(create_wrapper('a{}b')) + a{}b - @abc.abstractmethod - def update(self, pbar, width): - '''Updates the widget providing the total width the widget must fill. + >>> print(create_wrapper(('a', 'b'))) + a{}b + """ + if isinstance(wrapper, tuple) and len(wrapper) == 2: + a, b = wrapper + wrapper = (a or '') + '{}' + (b or '') + elif not wrapper: + return None + + if isinstance(wrapper, str): + assert '{}' in wrapper, 'Expected string with {} for formatting' + else: + raise RuntimeError( # noqa: TRY004 + 'Pass either a begin/end string as a tuple or a template string ' + 'with `{}`', + ) - pbar - a reference to the calling ProgressBar - width - The total width the widget must fill - ''' + return wrapper -class Timer(Widget): +def wrapper(function, wrapper_): + """Wrap the output of a function in a template string or a tuple with + begin/end strings. + + """ + wrapper_ = create_wrapper(wrapper_) + if not wrapper_: + return function - 'Widget which displays the elapsed seconds.' + @functools.wraps(function) + def wrap(*args, **kwargs): + return wrapper_.format(function(*args, **kwargs)) - TIME_SENSITIVE = True + return wrap + + +def create_marker(marker, wrap=None): + def _marker(progress, data, width): + if ( + progress.max_value is not base.UnknownLength + and progress.max_value > 0 + ): + length = int(progress.value / progress.max_value * width) + return marker * length + else: + return marker + + if isinstance(marker, str): + marker = converters.to_unicode(marker) + # Ruff is silly at times... the format is not compatible with the check + marker_length_error = 'Markers are required to be 1 char' + assert utils.len_color(marker) == 1, marker_length_error + return wrapper(_marker, wrap) + else: + return wrapper(marker, wrap) + + +class FormatWidgetMixin(abc.ABC): + """Mixin to format widgets using a formatstring. + + Variables available: + - max_value: The maximum value (can be None with iterators) + - value: The current value + - total_seconds_elapsed: The seconds since the bar started + - seconds_elapsed: The seconds since the bar started modulo 60 + - minutes_elapsed: The minutes since the bar started modulo 60 + - hours_elapsed: The hours since the bar started modulo 24 + - days_elapsed: The hours since the bar started + - time_elapsed: Shortcut for HH:MM:SS time since the bar started including + days + - percentage: Percentage as a float + """ - def __init__(self, format='Elapsed Time: %s'): + def __init__(self, format: str, new_style: bool = False, **kwargs): + self.new_style = new_style self.format = format - @staticmethod - def format_time(seconds): - 'Formats time as the string "HH:MM:SS".' + def get_format( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ) -> str: + return format or self.format + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ) -> str: + """Formats the widget into a string.""" + format_ = self.get_format(progress, data, format) + try: + if self.new_style: + return format_.format(**data) + else: + return format_ % data + except (TypeError, KeyError): + logger.exception( + 'Error while formatting %r with data: %r', + format_, + data, + ) + raise + + +class WidthWidgetMixin(abc.ABC): + """Mixing to make sure widgets are only visible if the screen is within a + specified size range so the progressbar fits on both large and small + screens. + + Variables available: + - min_width: Only display the widget if at least `min_width` is left + - max_width: Only display the widget if at most `max_width` is left + + >>> class Progress: + ... term_width = 0 + + >>> WidthWidgetMixin(5, 10).check_size(Progress) + False + >>> Progress.term_width = 5 + >>> WidthWidgetMixin(5, 10).check_size(Progress) + True + >>> Progress.term_width = 10 + >>> WidthWidgetMixin(5, 10).check_size(Progress) + True + >>> Progress.term_width = 11 + >>> WidthWidgetMixin(5, 10).check_size(Progress) + False + """ - return str(datetime.timedelta(seconds=int(seconds))) + def __init__(self, min_width=None, max_width=None, **kwargs): + self.min_width = min_width + self.max_width = max_width + + def check_size(self, progress: ProgressBarMixinBase): + max_width = self.max_width + min_width = self.min_width + if min_width and min_width > progress.term_width: + return False + elif max_width and max_width < progress.term_width: # noqa: SIM103 + return False + else: + return True - def update(self, pbar): - 'Updates the widget to show the elapsed time.' - return self.format % self.format_time(pbar.seconds_elapsed) +class TGradientColors(typing.TypedDict): + fg: types.Optional[terminal.OptionalColor | None] + bg: types.Optional[terminal.OptionalColor | None] -class ETA(Timer): +class TFixedColors(typing.TypedDict): + fg_none: types.Optional[terminal.Color | None] + bg_none: types.Optional[terminal.Color | None] - 'Widget which attempts to estimate the time of arrival.' - TIME_SENSITIVE = True +class WidgetBase(WidthWidgetMixin, metaclass=abc.ABCMeta): + """The base class for all widgets. - def _eta(self, pbar): - elapsed = pbar.seconds_elapsed - return elapsed * pbar.maxval / pbar.currval - elapsed + The ProgressBar will call the widget's update value when the widget should + be updated. The widget's size may change between calls, but the widget may + display incorrectly if the size changes drastically and repeatedly. - def update(self, pbar): - 'Updates the widget to show the ETA or total time when finished.' + The INTERVAL timedelta informs the ProgressBar that it should be + updated more often because it is time sensitive. - if pbar.currval == 0: - return 'ETA: --:--:--' - elif pbar.finished: - return 'Time: %s' % self.format_time(pbar.seconds_elapsed) - else: - return 'ETA: %s' % self.format_time(self._eta(pbar)) + The widgets are only visible if the screen is within a + specified size range so the progressbar fits on both large and small + screens. -class AdaptiveETA(ETA): - """Widget which attempts to estimate the time of arrival. + WARNING: Widgets can be shared between multiple progressbars so any state + information specific to a progressbar should be stored within the + progressbar instead of the widget. - Uses a weighted average of two estimates: - 1) ETA based on the total progress and time elapsed so far - 2) ETA based on the progress as per tha last 10 update reports + Variables available: + - min_width: Only display the widget if at least `min_width` is left + - max_width: Only display the widget if at most `max_width` is left + - weight: Widgets with a higher `weight` will be calculated before widgets + with a lower one + - copy: Copy this widget when initializing the progress bar so the + progressbar can be reused. Some widgets such as the FormatCustomText + require the shared state so this needs to be optional - The weight depends on the current progress so that to begin with the - total progress is used and at the end only the most recent progress is - used. """ - TIME_SENSITIVE = True + copy = True - def __init__(self, num_samples=10): - ETA.__init__(self) - self.num_samples = num_samples - self.samples = [] - self.sample_vals = [] - self.last_sample_val = None + @abc.abstractmethod + def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: + """Updates the widget. + + progress - a reference to the calling ProgressBar + """ + + _fixed_colors: ClassVar[TFixedColors] = TFixedColors( + fg_none=None, + bg_none=None, + ) + _gradient_colors: ClassVar[TGradientColors] = TGradientColors( + fg=None, + bg=None, + ) + # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict() + # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor | None]] = ( + # dict()) + _len: typing.Callable[[str | bytes], int] = len + + @functools.cached_property + def uses_colors(self): + for value in self._gradient_colors.values(): # pragma: no branch + if value is not None: # pragma: no branch + return True + + return any(value is not None for value in self._fixed_colors.values()) + + def _apply_colors(self, text: str, data: Data) -> str: + if self.uses_colors: + return terminal.apply_colors( + text, + data.get('percentage'), + **self._gradient_colors, + **self._fixed_colors, + ) + else: + return text - def _eta(self, pbar): - samples = self.samples - sample_vals = self.sample_vals - if pbar.currval != self.last_sample_val: - # Update the last sample counter, we only update if currval has - # changed - self.last_sample_val = pbar.currval + def __init__( + self, + *args, + fixed_colors=None, + gradient_colors=None, + **kwargs, + ): + if fixed_colors is not None: + self._fixed_colors.update(fixed_colors) - # Add a sample but limit the size to `num_samples` - samples.append(pbar.seconds_elapsed) - sample_vals.append(pbar.currval) - if len(samples) > self.num_samples: - samples.pop(0) - sample_vals.pop(0) + if gradient_colors is not None: + self._gradient_colors.update(gradient_colors) - if len(samples) <= 1: - # No samples so just return the normal ETA calculation - return ETA._eta(self, pbar) + if self.uses_colors: + self._len = utils.len_color - todo = pbar.maxval - pbar.currval - items = sample_vals[-1] - sample_vals[0] - duration = float(samples[-1] - samples[0]) - per_item = duration / items - return todo * per_item + super().__init__(*args, **kwargs) -class FileTransferSpeed(Widget): +class AutoWidthWidgetBase(WidgetBase, metaclass=abc.ABCMeta): + """The base class for all variable width widgets. - 'Widget for showing the transfer speed (useful for file transfers).' + This widget is much like the \\hfill command in TeX, it will expand to + fill the line. You can use more than one in the same line, and they will + all have the same width, and together will fill the line. + """ - format = '%6.2f %s%s/s' - prefixes = ' kMGTPEZY' + @abc.abstractmethod + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + ) -> str: + """Updates the widget providing the total width the widget must fill. + + progress - a reference to the calling ProgressBar + width - The total width the widget must fill + """ - def __init__(self, unit='B'): - self.unit = unit - def update(self, pbar): - 'Updates the widget with the current SI prefixed speed.' +class TimeSensitiveWidgetBase(WidgetBase, metaclass=abc.ABCMeta): + """The base class for all time sensitive widgets. - if pbar.seconds_elapsed < 2e-6 or pbar.currval < 2e-6: # =~ 0 - scaled = power = 0 - else: - speed = pbar.currval / pbar.seconds_elapsed - power = int(math.log(speed, 1000)) - scaled = speed / 1000. ** power + Some widgets like timers would become out of date unless updated at least + every `INTERVAL` + """ - return self.format % (scaled, self.prefixes[power], self.unit) + INTERVAL = datetime.timedelta(milliseconds=100) -class AnimatedMarker(Widget): +class FormatLabel(FormatWidgetMixin, WidgetBase): + """Displays a formatted label. - '''An animated marker for the progress bar which defaults to appear as if - it were rotating. - ''' + >>> label = FormatLabel('%(value)s', min_width=5, max_width=10) + >>> class Progress: + ... pass + >>> label = FormatLabel('{value} :: {value:^6}', new_style=True) + >>> str(label(Progress, dict(value='test'))) + 'test :: test ' - def __init__(self, markers='|/-\\'): - self.markers = markers - self.curmark = -1 + """ + + mapping: ClassVar[types.Dict[str, types.Tuple[str, types.Any]]] = dict( + finished=('end_time', None), + last_update=('last_update_time', None), + max=('max_value', None), + seconds=('seconds_elapsed', None), + start=('start_time', None), + elapsed=('total_seconds_elapsed', utils.format_time), + value=('value', None), + ) + + def __init__(self, format: str, **kwargs): + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + for name, (key, transform) in self.mapping.items(): + with contextlib.suppress(KeyError, ValueError, IndexError): + if transform is None: + data[name] = data[key] + else: + data[name] = transform(data[key]) - def update(self, pbar): - '''Updates the widget to show the next marker or the first marker when - finished''' + return FormatWidgetMixin.__call__(self, progress, data, format) - if pbar.finished: - return self.markers[0] - self.curmark = (self.curmark + 1) % len(self.markers) - return self.markers[self.curmark] +class Timer(FormatLabel, TimeSensitiveWidgetBase): + """WidgetBase which displays the elapsed seconds.""" -# Alias for backwards compatibility -RotatingMarker = AnimatedMarker + def __init__(self, format='Elapsed Time: %(elapsed)s', **kwargs): + if '%s' in format and '%(elapsed)s' not in format: + format = format.replace('%s', '%(elapsed)s') + FormatLabel.__init__(self, format=format, **kwargs) + TimeSensitiveWidgetBase.__init__(self, **kwargs) -class Counter(Widget): + # This is exposed as a static method for backwards compatibility + format_time = staticmethod(utils.format_time) - 'Displays the current count' - def __init__(self, format='%d'): - self.format = format +class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta): + """ + Mixing for widgets that average multiple measurements. + + Note that samples can be either an integer or a timedelta to indicate a + certain amount of time + + >>> class progress: + ... last_update_time = datetime.datetime.now() + ... value = 1 + ... extra = dict() + + >>> samples = SamplesMixin(samples=2) + >>> samples(progress, None, True) + (None, None) + >>> progress.last_update_time += datetime.timedelta(seconds=1) + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True + + >>> progress.last_update_time += datetime.timedelta(seconds=1) + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True + + >>> samples = SamplesMixin(samples=datetime.timedelta(seconds=1)) + >>> _, value = samples(progress, None) + >>> value + SliceableDeque([1, 1]) + + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True + """ - def update(self, pbar): - return self.format % pbar.currval + def __init__( + self, + samples=datetime.timedelta(seconds=2), + key_prefix=None, + **kwargs, + ): + self.samples = samples + self.key_prefix = (key_prefix or self.__class__.__name__) + '_' + TimeSensitiveWidgetBase.__init__(self, **kwargs) + + def get_sample_times(self, progress: ProgressBarMixinBase, data: Data): + return progress.extra.setdefault( + f'{self.key_prefix}sample_times', + containers.SliceableDeque(), + ) + + def get_sample_values(self, progress: ProgressBarMixinBase, data: Data): + return progress.extra.setdefault( + f'{self.key_prefix}sample_values', + containers.SliceableDeque(), + ) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + delta: bool = False, + ): + sample_times = self.get_sample_times(progress, data) + sample_values = self.get_sample_values(progress, data) + + if sample_times: + sample_time = sample_times[-1] + else: + sample_time = datetime.datetime.min + if progress.last_update_time - sample_time > self.INTERVAL: + # Add a sample but limit the size to `num_samples` + sample_times.append(progress.last_update_time) + sample_values.append(progress.value) + + if isinstance(self.samples, datetime.timedelta): + minimum_time = progress.last_update_time - self.samples + minimum_value = sample_values[-1] + while ( + sample_times[2:] + and minimum_time > sample_times[1] + and minimum_value > sample_values[1] + ): + sample_times.pop(0) + sample_values.pop(0) + elif len(sample_times) > self.samples: + sample_times.pop(0) + sample_values.pop(0) + + if delta: + if delta_time := sample_times[-1] - sample_times[0]: + delta_value = sample_values[-1] - sample_values[0] + return delta_time, delta_value + else: + return None, None + else: + return sample_times, sample_values -class Percentage(Widget): - 'Displays the current percentage as a number with a percent sign.' +class ETA(Timer): + """WidgetBase which attempts to estimate the time of arrival.""" + + def __init__( + self, + format_not_started='ETA: --:--:--', + format_finished='Time: %(elapsed)8s', + format='ETA: %(eta)8s', + format_zero='ETA: 00:00:00', + format_na='ETA: N/A', + **kwargs, + ): + if '%s' in format and '%(eta)s' not in format: + format = format.replace('%s', '%(eta)s') + + Timer.__init__(self, **kwargs) + self.format_not_started = format_not_started + self.format_finished = format_finished + self.format = format + self.format_zero = format_zero + self.format_NA = format_na + + def _calculate_eta( + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, + ): + """Updates the widget to show the ETA or total time when finished.""" + if elapsed: + # The max() prevents zero division errors + per_item = elapsed.total_seconds() / max(value, 1e-6) + remaining = progress.max_value - data['value'] + return remaining * per_item + else: + return 0 + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + """Updates the widget to show the ETA or total time when finished.""" + if value is None: + value = data['value'] + + if elapsed is None: + elapsed = data['time_elapsed'] + + eta_na = False + try: + data['eta_seconds'] = self._calculate_eta( + progress, + data, + value=value, + elapsed=elapsed, + ) + except TypeError: + data['eta_seconds'] = None + eta_na = True + + data['eta'] = None + if data['eta_seconds']: + with contextlib.suppress(ValueError, OverflowError, OSError): + data['eta'] = utils.format_time(data['eta_seconds']) + + if data['value'] == progress.min_value: + fmt = self.format_not_started + elif progress.end_time: + fmt = self.format_finished + elif data['eta']: + fmt = self.format + elif eta_na: + fmt = self.format_NA + else: + fmt = self.format_zero + + return Timer.__call__(self, progress, data, format=fmt) + + +class AbsoluteETA(ETA): + """Widget which attempts to estimate the absolute time of arrival.""" + + def _calculate_eta( + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, + ): + eta_seconds = ETA._calculate_eta(self, progress, data, value, elapsed) + now = datetime.datetime.now() + try: + return now + datetime.timedelta(seconds=eta_seconds) + except OverflowError: # pragma: no cover + return datetime.datetime.max + + def __init__( + self, + format_not_started='Estimated finish time: ----/--/-- --:--:--', + format_finished='Finished at: %(elapsed)s', + format='Estimated finish time: %(eta)s', + **kwargs, + ): + ETA.__init__( + self, + format_not_started=format_not_started, + format_finished=format_finished, + format=format, + **kwargs, + ) + + +class AdaptiveETA(ETA, SamplesMixin): + """WidgetBase which attempts to estimate the time of arrival. + + Uses a sampled average of the speed based on the 10 last updates. + Very convenient for resuming the progress halfway. + """ - def update(self, pbar): - return '%3d%%' % pbar.percentage() + exponential_smoothing: bool + exponential_smoothing_factor: float + + def __init__( + self, + exponential_smoothing=True, + exponential_smoothing_factor=0.1, + **kwargs, + ): + self.exponential_smoothing = exponential_smoothing + self.exponential_smoothing_factor = exponential_smoothing_factor + ETA.__init__(self, **kwargs) + SamplesMixin.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + elapsed, value = SamplesMixin.__call__( + self, + progress, + data, + delta=True, + ) + if not elapsed: + value = None + elapsed = 0 + + return ETA.__call__(self, progress, data, value=value, elapsed=elapsed) + + +class SmoothingETA(ETA): + """ + WidgetBase which attempts to estimate the time of arrival using an + exponential moving average (EMA) of the speed. + EMA applies more weight to recent data points and less to older ones, + and doesn't require storing all past values. This approach works well + with varying data points and smooths out fluctuations effectively. + """ -class FormatLabel(Timer): + smoothing_algorithm: algorithms.SmoothingAlgorithm + smoothing_parameters: dict[str, float] + + def __init__( + self, + smoothing_algorithm: type[ + algorithms.SmoothingAlgorithm + ] = algorithms.ExponentialMovingAverage, + smoothing_parameters: dict[str, float] | None = None, + **kwargs, + ): + self.smoothing_parameters = smoothing_parameters or {} + self.smoothing_algorithm = smoothing_algorithm( + **(self.smoothing_parameters or {}), + ) + ETA.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + if value is None: # pragma: no branch + value = data['value'] + + if elapsed is None: # pragma: no branch + elapsed = data['time_elapsed'] + + self.smoothing_algorithm.update(value, elapsed) + return ETA.__call__(self, progress, data, value=value, elapsed=elapsed) + + +class DataSize(FormatWidgetMixin, WidgetBase): + """ + Widget for showing an amount of data transferred/processed. - 'Displays a formatted label' + Automatically formats the value (assumed to be a count of bytes) with an + appropriate sized unit, based on the IEC binary prefixes (powers of 1024). + """ - mapping = { - 'elapsed': ('seconds_elapsed', Timer.format_time), - 'finished': ('finished', None), - 'last_update': ('last_update_time', None), - 'max': ('maxval', None), - 'seconds': ('seconds_elapsed', None), - 'start': ('start_time', None), - 'value': ('currval', None) - } + def __init__( + self, + variable='value', + format='%(scaled)5.1f %(prefix)s%(unit)s', + unit='B', + prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), + **kwargs, + ): + self.variable = variable + self.unit = unit + self.prefixes = prefixes + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + value = data[self.variable] + if value is not None: + scaled, power = utils.scale_1024(value, len(self.prefixes)) + else: + scaled = power = 0 - def __init__(self, format): - self.format = format + data['scaled'] = scaled + data['prefix'] = self.prefixes[power] + data['unit'] = self.unit - def update(self, pbar): - context = {} - for name, (key, transform) in self.mapping.items(): - try: - value = getattr(pbar, key) + return FormatWidgetMixin.__call__(self, progress, data, format) - if transform is None: - context[name] = value - else: - context[name] = transform(value) - except: # pragma: no cover - pass - return self.format % context +class FileTransferSpeed(FormatWidgetMixin, TimeSensitiveWidgetBase): + """ + Widget for showing the current transfer speed (useful for file transfers). + """ + def __init__( + self, + format='%(scaled)5.1f %(prefix)s%(unit)-s/s', + inverse_format='%(scaled)5.1f s/%(prefix)s%(unit)-s', + unit='B', + prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), + **kwargs, + ): + self.unit = unit + self.prefixes = prefixes + self.inverse_format = inverse_format + FormatWidgetMixin.__init__(self, format=format, **kwargs) + TimeSensitiveWidgetBase.__init__(self, **kwargs) + + def _speed(self, value, elapsed): + speed = float(value) / elapsed + return utils.scale_1024(speed, len(self.prefixes)) + + def __call__( + self, + progress: ProgressBarMixinBase, + data, + value=None, + total_seconds_elapsed=None, + ): + """Updates the widget with the current SI prefixed speed.""" + if value is None: + value = data['value'] + + elapsed = utils.deltas_to_seconds( + total_seconds_elapsed, + data['total_seconds_elapsed'], + ) + + if ( + value is not None + and elapsed is not None + and elapsed > 2e-6 + and value > 2e-6 + ): # =~ 0 + scaled, power = self._speed(value, elapsed) + else: + scaled = power = 0 -class SimpleProgress(Widget): + data['unit'] = self.unit + if power == 0 and scaled < 0.1: + if scaled > 0: + scaled = 1 / scaled + data['scaled'] = scaled + data['prefix'] = self.prefixes[0] + return FormatWidgetMixin.__call__( + self, + progress, + data, + self.inverse_format, + ) + else: + data['scaled'] = scaled + data['prefix'] = self.prefixes[power] + return FormatWidgetMixin.__call__(self, progress, data) + + +class AdaptiveTransferSpeed(FileTransferSpeed, SamplesMixin): + """Widget for showing the transfer speed based on the last X samples.""" + + def __init__(self, **kwargs): + FileTransferSpeed.__init__(self, **kwargs) + SamplesMixin.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data, + value=None, + total_seconds_elapsed=None, + ): + elapsed, value = SamplesMixin.__call__( + self, + progress, + data, + delta=True, + ) + return FileTransferSpeed.__call__(self, progress, data, value, elapsed) + + +class AnimatedMarker(TimeSensitiveWidgetBase): + """An animated marker for the progress bar which defaults to appear as if + it were rotating. + """ - 'Returns progress as a count of the total (e.g.: "5 of 47")' + def __init__( + self, + markers='|/-\\', + default=None, + fill='', + marker_wrap=None, + fill_wrap=None, + **kwargs, + ): + self.markers = markers + self.marker_wrap = create_wrapper(marker_wrap) + self.default = default or markers[0] + self.fill_wrap = create_wrapper(fill_wrap) + self.fill = create_marker(fill, self.fill_wrap) if fill else None + WidgetBase.__init__(self, **kwargs) + + def __call__(self, progress: ProgressBarMixinBase, data: Data, width=None): + """Updates the widget to show the next marker or the first marker when + finished. + """ + if progress.end_time: + return self.default + + marker = self.markers[data['updates'] % len(self.markers)] + if self.marker_wrap: + marker = self.marker_wrap.format(marker) + + if self.fill: + # Cut the last character so we can replace it with our marker + fill = self.fill( + progress, + data, + width - progress.custom_len(marker), # type: ignore + ) + else: + fill = '' - def __init__(self, sep=' of '): - self.sep = sep + # Python 3 returns an int when indexing bytes + if isinstance(marker, int): # pragma: no cover + marker = bytes(marker) + fill = fill.encode() + else: + # cast fill to the same type as marker + fill = type(marker)(fill) - def update(self, pbar): - return '%d%s%d' % (pbar.currval, self.sep, pbar.maxval) + return fill + marker # type: ignore -class Bar(WidgetHFill): +# Alias for backwards compatibility +RotatingMarker = AnimatedMarker - 'A progress bar which stretches to fill the line.' - def __init__(self, marker='#', left='|', right='|', fill=' ', - fill_left=True): - '''Creates a customizable progress bar. +class Counter(FormatWidgetMixin, WidgetBase): + """Displays the current count.""" + + def __init__(self, format='%(value)d', **kwargs): + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, format=format, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, + ): + return FormatWidgetMixin.__call__(self, progress, data, format) + + +class ColoredMixin: + _fixed_colors: ClassVar[TFixedColors] = TFixedColors( + fg_none=colors.yellow, + bg_none=None, + ) + _gradient_colors: ClassVar[TGradientColors] = TGradientColors( + fg=colors.gradient, + bg=None, + ) + # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict( + # fg_none=colors.yellow, bg_none=None) + # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor | + # None]] = dict(fg=colors.gradient, + # bg=None) + + +class Percentage(FormatWidgetMixin, ColoredMixin, WidgetBase): + """Displays the current percentage as a number with a percent sign.""" + + def __init__(self, format='%(percentage)3d%%', na='N/A%%', **kwargs): + self.na = na + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, format=format, **kwargs) + + def get_format( + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, + ): + # If percentage is not available, display N/A% + percentage = data.get('percentage', base.Undefined) + if not percentage and percentage != 0: + output = self.na + else: + output = FormatWidgetMixin.get_format(self, progress, data, format) + + return self._apply_colors(output, data) + + +class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase): + """Returns progress as a count of the total (e.g.: "5 of 47").""" + + max_width_cache: dict[ + str + | tuple[ + NumberT | types.Type[base.UnknownLength] | None, + NumberT | types.Type[base.UnknownLength] | None, + ], + types.Optional[int], + ] + + DEFAULT_FORMAT = '%(value_s)s of %(max_value_s)s' + + def __init__(self, format=DEFAULT_FORMAT, **kwargs): + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, format=format, **kwargs) + self.max_width_cache = dict() + # Pyright isn't happy when we set the key in the initialiser + self.max_width_cache['default'] = self.max_width or 0 + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, + ): + # If max_value is not available, display N/A + if data.get('max_value'): + data['max_value_s'] = data['max_value'] + else: + data['max_value_s'] = 'N/A' - marker - string or updatable object to use as a marker - left - string or updatable object to use as a left border - right - string or updatable object to use as a right border + # if value is not available it's the zeroth iteration + if data.get('value'): + data['value_s'] = data['value'] + else: + data['value_s'] = 0 + + formatted = FormatWidgetMixin.__call__( + self, + progress, + data, + format=format, + ) + + # Guess the maximum width from the min and max value + key = progress.min_value, progress.max_value + max_width: types.Optional[int] = self.max_width_cache.get( + key, + self.max_width, + ) + if not max_width: + temporary_data = data.copy() + for value in key: + if value is None: # pragma: no cover + continue + + temporary_data['value'] = value + if width := progress.custom_len( # pragma: no branch + FormatWidgetMixin.__call__( + self, + progress, + temporary_data, + format=format, + ), + ): + max_width = max(max_width or 0, width) + + self.max_width_cache[key] = max_width + + # Adjust the output to have a consistent size in all cases + if max_width: # pragma: no branch + formatted = formatted.rjust(max_width) + + return self._apply_colors(formatted, data) + + +class Bar(AutoWidthWidgetBase): + """A progress bar which stretches to fill the line.""" + + fg: terminal.OptionalColor | None = colors.gradient + bg: terminal.OptionalColor | None = None + + def __init__( + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=True, + marker_wrap=None, + **kwargs, + ): + """Creates a customizable progress bar. + + The callable takes the same parameters as the `__call__` method + + marker - string or callable object to use as a marker + left - string or callable object to use as a left border + right - string or callable object to use as a right border fill - character to use for the empty part of the progress bar fill_left - whether to fill from the left or the right - ''' - self.marker = marker - self.left = left - self.right = right - self.fill = fill + """ + self.marker = create_marker(marker, marker_wrap) + self.left = string_or_lambda(left) + self.right = string_or_lambda(right) + self.fill = string_or_lambda(fill) self.fill_left = fill_left - def update(self, pbar, width): - 'Updates the progress bar and its subcomponents' - - left, marked, right = (format_updatable(i, pbar) for i in - (self.left, self.marker, self.right)) - - width -= len(left) + len(right) - # Marked must *always* have length of 1 - if pbar.maxval: - marked *= int(pbar.currval / pbar.maxval * width) - else: # pragma: no cover - marked = '' + AutoWidthWidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, + ): + """Updates the progress bar and its subcomponents.""" + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + marker = converters.to_unicode(self.marker(progress, data, width)) + fill = converters.to_unicode(self.fill(progress, data, width)) + + # Make sure we ignore invisible characters when filling + width += len(marker) - progress.custom_len(marker) if self.fill_left: - return '%s%s%s' % (left, marked.ljust(width, self.fill), right) + marker = marker.ljust(width, fill) else: - return '%s%s%s' % (left, marked.rjust(width, self.fill), right) + marker = marker.rjust(width, fill) + if color: + marker = self._apply_colors(marker, data) -class ReverseBar(Bar): + return left + marker + right - 'A bar which has a marker which bounces from side to side.' - def __init__(self, marker='#', left='|', right='|', fill=' ', - fill_left=False): - '''Creates a customizable progress bar. +class ReverseBar(Bar): + """A bar which has a marker that goes from right to left.""" + + def __init__( + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=False, + **kwargs, + ): + """Creates a customizable progress bar. marker - string or updatable object to use as a marker left - string or updatable object to use as a left border right - string or updatable object to use as a right border fill - character to use for the empty part of the progress bar fill_left - whether to fill from the left or the right - ''' - self.marker = marker - self.left = left - self.right = right - self.fill = fill - self.fill_left = fill_left - - -class BouncingBar(Bar): + """ + Bar.__init__( + self, + marker=marker, + left=left, + right=right, + fill=fill, + fill_left=fill_left, + **kwargs, + ) + + +class BouncingBar(Bar, TimeSensitiveWidgetBase): + """A bar which has a marker which bounces from side to side.""" + + INTERVAL = datetime.timedelta(milliseconds=100) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, + ): + """Updates the progress bar and its subcomponents.""" + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + marker = converters.to_unicode(self.marker(progress, data, width)) + + fill = converters.to_unicode(self.fill(progress, data, width)) + + if width: # pragma: no branch + value = int( + data['total_seconds_elapsed'] / self.INTERVAL.total_seconds(), + ) + + a = value % width + b = width - a - 1 + if value % (width * 2) >= width: + a, b = b, a + + if self.fill_left: + marker = a * fill + marker + b * fill + else: + marker = b * fill + marker + a * fill + + return left + marker + right + + +class FormatCustomText(FormatWidgetMixin, WidgetBase): + mapping: types.Dict[str, types.Any] = dict() # noqa: RUF012 + copy = False + + def __init__( + self, + format: str, + mapping: types.Optional[types.Dict[str, types.Any]] = None, + **kwargs, + ): + self.format = format + self.mapping = mapping or self.mapping + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, **kwargs) + + def update_mapping(self, **mapping: types.Dict[str, types.Any]): + self.mapping.update(mapping) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + return FormatWidgetMixin.__call__( + self, + progress, + self.mapping, + format or self.format, + ) + + +class VariableMixin: + """Mixin to display a custom user variable.""" + + def __init__(self, name, **kwargs): + if not isinstance(name, str): + raise TypeError('Variable(): argument must be a string') + if len(name.split()) > 1: + raise ValueError('Variable(): argument must be single word') + self.name = name + + +class MultiRangeBar(Bar, VariableMixin): + """ + A bar with multiple sub-ranges, each represented by a different symbol. - def update(self, pbar, width): - 'Updates the progress bar and its subcomponents' + The various ranges are represented on a user-defined variable, formatted as - left, marker, right = (format_updatable(i, pbar) for i in - (self.left, self.marker, self.right)) + .. code-block:: python - width -= len(left) + len(right) + [['Symbol1', amount1], ['Symbol2', amount2], ...] + """ - if pbar.finished: - return '%s%s%s' % (left, width * marker, right) + def __init__(self, name, markers, **kwargs): + VariableMixin.__init__(self, name) + Bar.__init__(self, **kwargs) + self.markers = [string_or_lambda(marker) for marker in markers] + + def get_values(self, progress: ProgressBarMixinBase, data: Data): + return data['variables'][self.name] or [] + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, + ): + """Updates the progress bar and its subcomponents.""" + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + values = self.get_values(progress, data) + + values_sum = sum(values) + if width and values_sum: + middle = '' + values_accumulated = 0 + width_accumulated = 0 + for marker, value in zip(self.markers, values): + marker = converters.to_unicode(marker(progress, data, width)) + assert progress.custom_len(marker) == 1 + + values_accumulated += value + item_width = int(values_accumulated / values_sum * width) + item_width -= width_accumulated + width_accumulated += item_width + middle += item_width * marker + else: + fill = converters.to_unicode(self.fill(progress, data, width)) + assert progress.custom_len(fill) == 1 + middle = fill * width + + return left + middle + right + + +class MultiProgressBar(MultiRangeBar): + def __init__( + self, + name, + # NOTE: the markers are not whitespace even though some + # terminals don't show the characters correctly! + markers=' ▁▂▃▄▅▆▇█', + **kwargs, + ): + MultiRangeBar.__init__( + self, + name=name, + markers=list(reversed(markers)), + **kwargs, + ) + + def get_values(self, progress: ProgressBarMixinBase, data: Data): + ranges = [0.0] * len(self.markers) + for value in data['variables'][self.name] or []: + if not isinstance(value, (int, float)): + # Progress is (value, max) + progress_value, progress_max = value + value = float(progress_value) / float(progress_max) + + if not 0 <= value <= 1: + raise ValueError( + 'Range value needs to be in the range [0..1], ' + f'got {value}', + ) + + range_ = value * (len(ranges) - 1) + pos = int(range_) + frac = range_ % 1 + ranges[pos] += 1 - frac + if frac: + ranges[pos + 1] += frac + + if self.fill_left: # pragma: no branch + ranges = list(reversed(ranges)) + + return ranges + + +class GranularMarkers: + smooth = ' ▏▎▍▌▋▊▉█' + bar = ' ▁▂▃▄▅▆▇█' + snake = ' ▖▌▛█' + fade_in = ' ░▒▓█' + dots = ' ⡀⡄⡆⡇⣇⣧⣷⣿' + growing_circles = ' .oO' + + +class GranularBar(AutoWidthWidgetBase): + """A progressbar that can display progress at a sub-character granularity + by using multiple marker characters. + + Examples of markers: + - Smooth: ` ▏▎▍▌▋▊▉█` (default) + - Bar: ` ▁▂▃▄▅▆▇█` + - Snake: ` ▖▌▛█` + - Fade in: ` ░▒▓█` + - Dots: ` ⡀⡄⡆⡇⣇⣧⣷⣿` + - Growing circles: ` .oO` + + The markers can be accessed through GranularMarkers. GranularMarkers.dots + for example + """ - position = int(pbar.currval % (width * 2 - 1)) - if position > width: - position = width * 2 - position - lpad = self.fill * (position - 1) - rpad = self.fill * (width - len(marker) - len(lpad)) + def __init__( + self, + markers=GranularMarkers.smooth, + left='|', + right='|', + **kwargs, + ): + """Creates a customizable progress bar. + + markers - string of characters to use as granular progress markers. The + first character should represent 0% and the last 100%. + Ex: ` .oO`. + left - string or callable object to use as a left border + right - string or callable object to use as a right border + """ + self.markers = markers + self.left = string_or_lambda(left) + self.right = string_or_lambda(right) + + AutoWidthWidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + ): + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + + max_value = progress.max_value + if ( + max_value is not base.UnknownLength + and typing.cast(float, max_value) > 0 + ): + percent = progress.value / max_value # type: ignore + else: + percent = 0 + + num_chars = percent * width + + marker = self.markers[-1] * int(num_chars) + + if marker_idx := int((num_chars % 1) * (len(self.markers) - 1)): + marker += self.markers[marker_idx] + + marker = converters.to_unicode(marker) + + # Make sure we ignore invisible characters when filling + width += len(marker) - progress.custom_len(marker) + marker = marker.ljust(width, self.markers[0]) + + return left + marker + right + + +class FormatLabelBar(FormatLabel, Bar): + """A bar which has a formatted label in the center.""" + + def __init__(self, format, **kwargs): + FormatLabel.__init__(self, format, **kwargs) + Bar.__init__(self, **kwargs) + + def __call__( # type: ignore + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + format: FormatString = None, + ): + center = FormatLabel.__call__(self, progress, data, format=format) + bar = Bar.__call__(self, progress, data, width, color=False) + + # Aligns the center of the label to the center of the bar + center_len = progress.custom_len(center) + center_left = int((width - center_len) / 2) + center_right = center_left + center_len + + return ( + self._apply_colors( + bar[:center_left], + data, + ) + + self._apply_colors( + center, + data, + ) + + self._apply_colors( + bar[center_right:], + data, + ) + ) + + +class PercentageLabelBar(Percentage, FormatLabelBar): + """A bar which displays the current percentage in the center.""" + + # %3d adds an extra space that makes it look off-center + # %2d keeps the label somewhat consistently in-place + def __init__(self, format='%(percentage)2d%%', na='N/A%%', **kwargs): + Percentage.__init__(self, format, na=na, **kwargs) + FormatLabelBar.__init__(self, format, **kwargs) + + def __call__( # type: ignore + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + format: FormatString = None, + ): + return super().__call__(progress, data, width, format=format) + + +class Variable(FormatWidgetMixin, VariableMixin, WidgetBase): + """Displays a custom variable.""" + + def __init__( + self, + name, + format='{name}: {formatted_value}', + width=6, + precision=3, + **kwargs, + ): + """Creates a Variable associated with the given name.""" + self.format = format + self.width = width + self.precision = precision + VariableMixin.__init__(self, name=name) + WidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + value = data['variables'][self.name] + context = data.copy() + context['value'] = value + context['name'] = self.name + context['width'] = self.width + context['precision'] = self.precision + + try: + # Make sure to try and cast the value first, otherwise the + # formatting will generate warnings/errors on newer Python releases + value = float(value) + fmt = '{value:{width}.{precision}}' + context['formatted_value'] = fmt.format(**context) + except (TypeError, ValueError): + if value: + context['formatted_value'] = '{value:{width}}'.format( + **context, + ) + else: + context['formatted_value'] = '-' * self.width + + return self.format.format(**context) + + +class DynamicMessage(Variable): + """Kept for backwards compatibility, please use `Variable` instead.""" + + +class CurrentTime(FormatWidgetMixin, TimeSensitiveWidgetBase): + """Widget which displays the current (date)time with seconds resolution.""" + + INTERVAL = datetime.timedelta(seconds=1) + + def __init__( + self, + format='Current Time: %(current_time)s', + microseconds=False, + **kwargs, + ): + self.microseconds = microseconds + FormatWidgetMixin.__init__(self, format=format, **kwargs) + TimeSensitiveWidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + data['current_time'] = self.current_time() + data['current_datetime'] = self.current_datetime() + + return FormatWidgetMixin.__call__(self, progress, data, format=format) + + def current_datetime(self): + now = datetime.datetime.now() + if not self.microseconds: + now = now.replace(microsecond=0) + + return now + + def current_time(self): + return self.current_datetime().time() + + +class JobStatusBar(Bar, VariableMixin): + """ + Widget which displays the job status as markers on the bar. + + The status updates can be given either as a boolean or as a string. If it's + a string, it will be displayed as-is. If it's a boolean, it will be + displayed as a marker (default: '█' for success, 'X' for failure) + configurable through the `success_marker` and `failure_marker` parameters. + + Args: + name: The name of the variable to use for the status updates. + left: The left border of the bar. + right: The right border of the bar. + fill: The fill character of the bar. + fill_left: Whether to fill the bar from the left or the right. + success_fg_color: The foreground color to use for successful jobs. + success_bg_color: The background color to use for successful jobs. + success_marker: The marker to use for successful jobs. + failure_fg_color: The foreground color to use for failed jobs. + failure_bg_color: The background color to use for failed jobs. + failure_marker: The marker to use for failed jobs. + """ - # Swap if we want to bounce the other way - if not self.fill_left: - rpad, lpad = lpad, rpad + success_fg_color: terminal.Color | None = colors.green + success_bg_color: terminal.Color | None = None + success_marker: str = '█' + failure_fg_color: terminal.Color | None = colors.red + failure_bg_color: terminal.Color | None = None + failure_marker: str = 'X' + job_markers: list[str] + + def __init__( + self, + name: str, + left='|', + right='|', + fill=' ', + fill_left=True, + success_fg_color=colors.green, + success_bg_color=None, + success_marker='█', + failure_fg_color=colors.red, + failure_bg_color=None, + failure_marker='X', + **kwargs, + ): + VariableMixin.__init__(self, name) + self.name = name + self.job_markers = [] + self.left = string_or_lambda(left) + self.right = string_or_lambda(right) + self.fill = string_or_lambda(fill) + self.success_fg_color = success_fg_color + self.success_bg_color = success_bg_color + self.success_marker = success_marker + self.failure_fg_color = failure_fg_color + self.failure_bg_color = failure_bg_color + self.failure_marker = failure_marker + + Bar.__init__( + self, + left=left, + right=right, + fill=fill, + fill_left=fill_left, + **kwargs, + ) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, + ): + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + + status: str | bool | None = data['variables'].get(self.name) + + if width and status is not None: + if status is True: + marker = self.success_marker + fg_color = self.success_fg_color + bg_color = self.success_bg_color + elif status is False: # pragma: no branch + marker = self.failure_marker + fg_color = self.failure_fg_color + bg_color = self.failure_bg_color + else: # pragma: no cover + marker = status + fg_color = bg_color = None + + marker = converters.to_unicode(marker) + if fg_color: # pragma: no branch + marker = fg_color.fg(marker) + if bg_color: # pragma: no cover + marker = bg_color.bg(marker) + + self.job_markers.append(marker) + marker = ''.join(self.job_markers) + width -= progress.custom_len(marker) + + fill = converters.to_unicode(self.fill(progress, data, width)) + fill = self._apply_colors(fill * width, data) + + if self.fill_left: # pragma: no branch + marker += fill + else: # pragma: no cover + marker = fill + marker + else: + marker = '' - return '%s%s%s%s%s' % (left, lpad, marker, rpad, right) + return left + marker + right diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..c569a2a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,217 @@ + +[project] +authors = [{ name = 'Rick van Hattem (Wolph)', email = 'wolph@wol.ph' }] +dynamic = ['version'] +keywords = [ + 'REPL', + 'animated', + 'bar', + 'color', + 'console', + 'duration', + 'efficient', + 'elapsed', + 'eta', + 'feedback', + 'live', + 'meter', + 'monitor', + 'monitoring', + 'multi-threaded', + 'progress', + 'progress-bar', + 'progressbar', + 'progressmeter', + 'python', + 'rate', + 'simple', + 'speed', + 'spinner', + 'stats', + 'terminal', + 'throughput', + 'time', + 'visual', +] +license = { text = 'BSD-3-Clause' } +name = 'progressbar2' +requires-python = '>=3.8' + +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Development Status :: 6 - Mature', + 'Environment :: Console', + 'Environment :: MacOS X', + 'Environment :: Other Environment', + 'Environment :: Win32 (MS Windows)', + 'Environment :: X11 Applications', + 'Framework :: IPython', + 'Framework :: Jupyter', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Other Audience', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: MacOS', + 'Operating System :: Microsoft :: MS-DOS', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: Microsoft', + 'Operating System :: POSIX :: BSD :: FreeBSD', + 'Operating System :: POSIX :: BSD', + 'Operating System :: POSIX :: Linux', + 'Operating System :: POSIX :: SunOS/Solaris', + 'Operating System :: POSIX', + 'Operating System :: Unix', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: Implementation :: IronPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Programming Language :: Python :: Implementation', + 'Programming Language :: Python', + 'Programming Language :: Unix Shell', + 'Topic :: Desktop Environment', + 'Topic :: Education :: Computer Aided Instruction (CAI)', + 'Topic :: Education :: Testing', + 'Topic :: Office/Business', + 'Topic :: Other/Nonlisted Topic', + 'Topic :: Software Development :: Build Tools', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Pre-processors', + 'Topic :: Software Development :: User Interfaces', + 'Topic :: System :: Installation/Setup', + 'Topic :: System :: Logging', + 'Topic :: System :: Monitoring', + 'Topic :: System :: Shells', + 'Topic :: Terminals', + 'Topic :: Utilities', +] +description = 'A Python Progressbar library to provide visual (yet text based) progress to long running operations.' +readme = 'README.rst' + +dependencies = ['python-utils >= 3.8.1'] + +[tool.setuptools.dynamic] +version = { attr = 'progressbar.__about__.__version__' } + +[tool.setuptools.packages.find] +exclude = ['docs*', 'tests*'] + +[tool.setuptools] +include-package-data = true + +[project.scripts] +progressbar = 'progressbar.__main__:main' + +[project.optional-dependencies] +docs = ['sphinx>=1.8.5', 'sphinx-autodoc-typehints>=1.6.0'] +tests = [ + 'dill>=0.3.6', + 'flake8>=3.7.7', + 'freezegun>=0.3.11', + 'pytest-cov>=2.6.1', + 'pytest-mypy', + 'pytest>=4.6.9', + 'sphinx>=1.8.5', + 'pywin32; sys_platform == "win32"', +] + +[project.urls] +bugs = 'https://github.com/wolph/python-progressbar/issues' +documentation = 'https://progressbar-2.readthedocs.io/en/latest/' +repository = 'https://github.com/wolph/python-progressbar/' + +[build-system] +build-backend = 'setuptools.build_meta' +requires = ['setuptools', 'setuptools-scm'] + +[tool.codespell] +skip = '*/htmlcov,./docs/_build,*.asc' + +ignore-words-list = 'datas,numbert' + +[tool.black] +line-length = 79 +skip-string-normalization = true + +[tool.mypy] +packages = ['progressbar', 'tests'] +exclude = [ + '^docs$', + '^tests/original_examples.py$', + '^examples.py$', +] + +[tool.coverage.run] +branch = true +source = [ + 'progressbar', + 'tests', +] +omit = [ + '*/mock/*', + '*/nose/*', + '.tox/*', + '*/os_specific/*', +] +[tool.coverage.paths] +source = [ + 'progressbar', +] + +[tool.coverage.report] +fail_under = 100 +exclude_lines = [ + 'pragma: no cover', + '@abc.abstractmethod', + 'def __repr__', + 'if self.debug:', + 'if settings.DEBUG', + 'raise AssertionError', + 'raise NotImplementedError', + 'if 0:', + 'if __name__ == .__main__.:', + 'if types.TYPE_CHECKING:', + '@typing.overload', + 'if os.name == .nt.:', +] + +[tool.pyright] +include= ['progressbar'] +exclude= ['examples'] +ignore= ['docs'] +#strict = [ +# 'progressbar/algorithms.py', +# 'progressbar/env.py', +# 'progressbar/shortcuts.py', +## 'progressbar/multi.py', +## 'progressbar/__init__.py', +# 'progressbar/terminal/__init__.py', +## 'progressbar/terminal/stream.py', +# 'progressbar/terminal/os_specific/__init__.py', +# 'progressbar/terminal/os_specific/posix.py', +## 'progressbar/terminal/os_specific/windows.py', +## 'progressbar/terminal/base.py', +## 'progressbar/terminal/colors.py', +## 'progressbar/widgets.py', +## 'progressbar/utils.py', +# 'progressbar/__about__.py', +## 'progressbar/bar.py', +# 'progressbar/__main__.py', +# 'progressbar/base.py', +#] + +reportIncompatibleMethodOverride = false +reportUnnecessaryIsInstance = false +reportUnnecessaryCast = false +reportUnnecessaryTypeAssertion = false +reportUnnecessaryComparison = false +reportUnnecessaryContains = false diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..08a11301 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,28 @@ +[pytest] +python_files = + progressbar/*.py + tests/*.py + +addopts = + --cov progressbar + --cov-report=html + --cov-report=term-missing + --cov-report=xml + --cov-config=./pyproject.toml + --no-cov-on-fail + --doctest-modules + +norecursedirs = + .* + _* + build + dist + docs + progressbar/terminal/os_specific + tmp* + +filterwarnings = + ignore::DeprecationWarning + +markers = + no_freezegun: Disable automatic freezegun wrapping diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..f6efc61d --- /dev/null +++ b/ruff.toml @@ -0,0 +1,107 @@ +# We keep the ruff configuration separate so it can easily be shared across +# all projects + +target-version = 'py38' + +#src = ['progressbar'] +exclude = [ + '.venv', + '.tox', + 'test.py', +] + +lint.ignore = [ + 'A001', # Variable {name} is shadowing a Python builtin + 'A002', # Argument {name} is shadowing a Python builtin + 'A003', # Class attribute {name} is shadowing a Python builtin + 'B023', # function-uses-loop-variable + 'B024', # `FormatWidgetMixin` is an abstract base class, but it has no abstract methods + 'D205', # blank-line-after-summary + 'D212', # multi-line-summary-first-line + 'RET505', # Unnecessary `else` after `return` statement + 'TRY003', # Avoid specifying long messages outside the exception class + 'RET507', # Unnecessary `elif` after `continue` statement + 'C405', # Unnecessary {obj_type} literal (rewrite as a set literal) + 'C406', # Unnecessary {obj_type} literal (rewrite as a dict literal) + 'C408', # Unnecessary {obj_type} call (rewrite as a literal) + 'SIM114', # Combine `if` branches using logical `or` operator + 'RET506', # Unnecessary `else` after `raise` statement + 'Q001', # Remove bad quotes + 'Q002', # Remove bad quotes + 'COM812', # Missing trailing comma in a list + 'ISC001', # String concatenation with implicit str conversion + 'SIM108', # Ternary operators are not always more readable +] +line-length = 79 +lint.select = [ + 'A', # flake8-builtins + 'ASYNC', # flake8 async checker + 'B', # flake8-bugbear + 'C4', # flake8-comprehensions + 'C90', # mccabe + 'COM', # flake8-commas + + ## Require docstrings for all public methods, would be good to enable at some point + # 'D', # pydocstyle + + 'E', # pycodestyle error ('W' for warning) + 'F', # pyflakes + 'FA', # flake8-future-annotations + 'I', # isort + 'ICN', # flake8-import-conventions + 'INP', # flake8-no-pep420 + 'ISC', # flake8-implicit-str-concat + 'N', # pep8-naming + 'NPY', # NumPy-specific rules + 'PERF', # perflint, + 'PIE', # flake8-pie + 'Q', # flake8-quotes + + 'RET', # flake8-return + 'RUF', # Ruff-specific rules + 'SIM', # flake8-simplify + 'T20', # flake8-print + 'TD', # flake8-todos + 'TRY', # tryceratops + 'UP', # pyupgrade +] + +[lint.per-file-ignores] +'tests/*' = ['INP001', 'T201', 'T203'] +'examples.py' = ['T201', 'N806'] +'docs/conf.py' = ['E501', 'INP001'] +'docs/_theme/flask_theme_support.py' = ['RUF012', 'INP001'] + +[lint.pydocstyle] +convention = 'google' +ignore-decorators = [ + 'typing.overload', + 'typing.override', +] + +[lint.isort] +case-sensitive = true +combine-as-imports = true +force-wrap-aliases = true + +[lint.flake8-quotes] +docstring-quotes = 'single' +inline-quotes = 'single' +multiline-quotes = 'single' + +[format] +line-ending = 'lf' +indent-style = 'space' +quote-style = 'single' +docstring-code-format = true +skip-magic-trailing-comma = false +exclude = [ + '__init__.py', +] + +[lint.pycodestyle] +max-line-length = 79 + +[lint.flake8-pytest-style] +mark-parentheses = true + diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 8c03d3ee..00000000 --- a/setup.cfg +++ /dev/null @@ -1,30 +0,0 @@ -[metadata] -description-file = README.rst - -[nosetests] -verbosity=3 -detailed-errors=1 -debug=nose.loader - -with-doctest=1 - -with-coverage=1 -cover-package=progressbar -cover-branches=1 -cover-min-percentage=100 - -#pdb=1 -pdb-failures=1 - -with-tissue=1 -tissue-ignore=W391 -tissue-package=progressbar - -[build_sphinx] -source-dir = docs/ -build-dir = docs/_build -all_files = 1 - -[upload_sphinx] -upload-dir = docs/_build/html - diff --git a/setup.py b/setup.py deleted file mode 100644 index 6cf7f23b..00000000 --- a/setup.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/python - -import os -from setuptools import setup, find_packages -import progressbar - -# TODO: I don't believe this should be in here. This should be done on package -# creation only -try: - readme = 'README.txt' - info = 'progressbar/__init__.py' - - if (not os.path.exists(readme) or - os.stat(info).st_mtime > os.stat(readme).st_mtime): - - open(readme, 'w').write(progressbar.__doc__) -except: - pass - -setup( - name='progressbar2', - version=progressbar.__version__, - packages=find_packages(), - - description=progressbar.__doc__.split('\n')[0], - long_description=progressbar.__doc__, - - author=progressbar.__author__, - maintainer=progressbar.__author__, - author_email=progressbar.__author_email__, - maintainer_email=progressbar.__author_email__, - - url='https://github.com/WoLpH/python-progressbar', - license='LICENSE.txt', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: ' - 'GNU Library or Lesser General Public License (LGPL)', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.4', - 'Programming Language :: Python :: 2.5', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Topic :: Software Development :: Libraries', - 'Topic :: Software Development :: User Interfaces', - 'Topic :: Terminals' - ], -) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..787e643e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import logging +import time +import timeit +from datetime import datetime + +import freezegun +import pytest + +import progressbar + +LOG_LEVELS: dict[str, int] = { + '0': logging.ERROR, + '1': logging.WARNING, + '2': logging.INFO, + '3': logging.DEBUG, +} + + +def pytest_configure(config) -> None: + logging.basicConfig( + level=LOG_LEVELS.get(config.option.verbose, logging.DEBUG), + ) + + +@pytest.fixture(autouse=True) +def small_interval(monkeypatch) -> None: + # Remove the update limit for tests by default + monkeypatch.setattr( + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + 1e-6, + ) + monkeypatch.setattr(timeit, 'default_timer', time.time) + + +@pytest.fixture(autouse=True) +def sleep_faster(monkeypatch): + # The timezone offset in seconds, add 10 seconds to make sure we don't + # accidentally get the wrong hour + offset_seconds = (datetime.now() - datetime.utcnow()).seconds + 10 + offset_hours = int(offset_seconds / 3600) + + freeze_time = freezegun.freeze_time(tz_offset=offset_hours) + with freeze_time as fake_time: + monkeypatch.setattr('time.sleep', fake_time.tick) + monkeypatch.setattr('timeit.default_timer', time.time) + yield freeze_time diff --git a/tests/original_examples.py b/tests/original_examples.py new file mode 100644 index 00000000..7f1db168 --- /dev/null +++ b/tests/original_examples.py @@ -0,0 +1,299 @@ +#!/usr/bin/python + +import sys +import time + +from progressbar import ( + ETA, + AdaptiveETA, + AnimatedMarker, + Bar, + BouncingBar, + Counter, + FileTransferSpeed, + FormatLabel, + Percentage, + ProgressBar, + ReverseBar, + RotatingMarker, + SimpleProgress, + Timer, + UnknownLength, +) + +examples = [] + + +def example(fn): + try: + name = 'Example %d' % int(fn.__name__[7:]) + except Exception: + name = fn.__name__ + + def wrapped(): + try: + sys.stdout.write(f'Running: {name}\n') + fn() + sys.stdout.write('\n') + except KeyboardInterrupt: + sys.stdout.write('\nSkipping example.\n\n') + + examples.append(wrapped) + return wrapped + + +@example +def example0() -> None: + pbar = ProgressBar(widgets=[Percentage(), Bar()], maxval=300).start() + for i in range(300): + time.sleep(0.01) + pbar.update(i + 1) + pbar.finish() + + +@example +def example1() -> None: + widgets = [ + 'Test: ', + Percentage(), + ' ', + Bar(marker=RotatingMarker()), + ' ', + ETA(), + ' ', + FileTransferSpeed(), + ] + pbar = ProgressBar(widgets=widgets, maxval=10000).start() + for i in range(1000): + # do something + pbar.update(10 * i + 1) + pbar.finish() + + +@example +def example2() -> None: + class CrazyFileTransferSpeed(FileTransferSpeed): + """It's bigger between 45 and 80 percent.""" + + def update(self, pbar): + if 45 < pbar.percentage() < 80: + return 'Bigger Now ' + FileTransferSpeed.update(self, pbar) + else: + return FileTransferSpeed.update(self, pbar) + + widgets = [ + CrazyFileTransferSpeed(), + ' <<<', + Bar(), + '>>> ', + Percentage(), + ' ', + ETA(), + ] + pbar = ProgressBar(widgets=widgets, maxval=10000) + # maybe do something + pbar.start() + for i in range(2000): + # do something + pbar.update(5 * i + 1) + pbar.finish() + + +@example +def example3() -> None: + widgets = [Bar('>'), ' ', ETA(), ' ', ReverseBar('<')] + pbar = ProgressBar(widgets=widgets, maxval=10000).start() + for i in range(1000): + # do something + pbar.update(10 * i + 1) + pbar.finish() + + +@example +def example4() -> None: + widgets = [ + 'Test: ', + Percentage(), + ' ', + Bar(marker='0', left='[', right=']'), + ' ', + ETA(), + ' ', + FileTransferSpeed(), + ] + pbar = ProgressBar(widgets=widgets, maxval=500) + pbar.start() + for i in range(100, 500 + 1, 50): + time.sleep(0.2) + pbar.update(i) + pbar.finish() + + +@example +def example5() -> None: + pbar = ProgressBar(widgets=[SimpleProgress()], maxval=17).start() + for i in range(17): + time.sleep(0.2) + pbar.update(i + 1) + pbar.finish() + + +@example +def example6() -> None: + pbar = ProgressBar().start() + for i in range(100): + time.sleep(0.01) + pbar.update(i + 1) + pbar.finish() + + +@example +def example7() -> None: + pbar = ProgressBar() # Progressbar can guess maxval automatically. + for _i in pbar(range(80)): + time.sleep(0.01) + + +@example +def example8() -> None: + pbar = ProgressBar(maxval=80) # Progressbar can't guess maxval. + for _i in pbar(i for i in range(80)): + time.sleep(0.01) + + +@example +def example9() -> None: + pbar = ProgressBar(widgets=['Working: ', AnimatedMarker()]) + for _i in pbar(i for i in range(50)): + time.sleep(0.08) + + +@example +def example10() -> None: + widgets = ['Processed: ', Counter(), ' lines (', Timer(), ')'] + pbar = ProgressBar(widgets=widgets) + for _i in pbar(i for i in range(150)): + time.sleep(0.1) + + +@example +def example11() -> None: + widgets = [FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)')] + pbar = ProgressBar(widgets=widgets) + for _i in pbar(i for i in range(150)): + time.sleep(0.1) + + +@example +def example12() -> None: + widgets = ['Balloon: ', AnimatedMarker(markers='.oO@* ')] + pbar = ProgressBar(widgets=widgets) + for _i in pbar(i for i in range(24)): + time.sleep(0.3) + + +@example +def example13() -> None: + # You may need python 3.x to see this correctly + try: + widgets = ['Arrows: ', AnimatedMarker(markers='←↖↑↗→↘↓↙')] + pbar = ProgressBar(widgets=widgets) + for _i in pbar(i for i in range(24)): + time.sleep(0.3) + except UnicodeError: + sys.stdout.write('Unicode error: skipping example') + + +@example +def example14() -> None: + # You may need python 3.x to see this correctly + try: + widgets = ['Arrows: ', AnimatedMarker(markers='◢◣◤◥')] + pbar = ProgressBar(widgets=widgets) + for _i in pbar(i for i in range(24)): + time.sleep(0.3) + except UnicodeError: + sys.stdout.write('Unicode error: skipping example') + + +@example +def example15() -> None: + # You may need python 3.x to see this correctly + try: + widgets = ['Wheels: ', AnimatedMarker(markers='◐◓◑◒')] + pbar = ProgressBar(widgets=widgets) + for _i in pbar(i for i in range(24)): + time.sleep(0.3) + except UnicodeError: + sys.stdout.write('Unicode error: skipping example') + + +@example +def example16() -> None: + widgets = [FormatLabel('Bouncer: value %(value)d - '), BouncingBar()] + pbar = ProgressBar(widgets=widgets) + for _i in pbar(i for i in range(180)): + time.sleep(0.05) + + +@example +def example17() -> None: + widgets = [ + FormatLabel('Animated Bouncer: value %(value)d - '), + BouncingBar(marker=RotatingMarker()), + ] + + pbar = ProgressBar(widgets=widgets) + for _i in pbar(i for i in range(180)): + time.sleep(0.05) + + +@example +def example18() -> None: + widgets = [Percentage(), ' ', Bar(), ' ', ETA(), ' ', AdaptiveETA()] + pbar = ProgressBar(widgets=widgets, maxval=500) + pbar.start() + for i in range(500): + time.sleep(0.01 + (i < 100) * 0.01 + (i > 400) * 0.9) + pbar.update(i + 1) + pbar.finish() + + +@example +def example19() -> None: + pbar = ProgressBar() + for _i in pbar([]): + pass + pbar.finish() + + +@example +def example20() -> None: + """Widgets that behave differently when length is unknown""" + widgets = [ + '[When length is unknown at first]', + ' Progress: ', + SimpleProgress(), + ', Percent: ', + Percentage(), + ' ', + ETA(), + ' ', + AdaptiveETA(), + ] + pbar = ProgressBar(widgets=widgets, maxval=UnknownLength) + pbar.start() + for i in range(20): + time.sleep(0.5) + if i == 10: + pbar.maxval = 20 + pbar.update(i + 1) + pbar.finish() + + +if __name__ == '__main__': + try: + for example in examples: + example() + except KeyboardInterrupt: + sys.stdout.write('\nQuitting examples.\n') diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..9628e3fa --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +-e.[tests] diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py new file mode 100644 index 00000000..a6cc6467 --- /dev/null +++ b/tests/test_algorithms.py @@ -0,0 +1,58 @@ +from datetime import timedelta + +import pytest + +from progressbar import algorithms + + +def test_ema_initialization() -> None: + ema = algorithms.ExponentialMovingAverage() + assert ema.alpha == 0.5 + assert ema.value == 0 + + +@pytest.mark.parametrize( + 'alpha, new_value, expected', + [ + (0.5, 10, 5), + (0.1, 20, 2), + (0.9, 30, 27), + (0.3, 15, 4.5), + (0.7, 40, 28), + (0.5, 0, 0), + (0.2, 100, 20), + (0.8, 50, 40), + ], +) +def test_ema_update(alpha, new_value: float, expected) -> None: + ema = algorithms.ExponentialMovingAverage(alpha) + result = ema.update(new_value, timedelta(seconds=1)) + assert result == expected + + +def test_dema_initialization() -> None: + dema = algorithms.DoubleExponentialMovingAverage() + assert dema.alpha == 0.5 + assert dema.ema1 == 0 + assert dema.ema2 == 0 + + +@pytest.mark.parametrize( + 'alpha, new_value, expected', + [ + (0.5, 10, 7.5), + (0.1, 20, 3.8), + (0.9, 30, 29.7), + (0.3, 15, 7.65), + (0.5, 0, 0), + (0.2, 100, 36.0), + (0.8, 50, 48.0), + ], +) +def test_dema_update(alpha, new_value: float, expected) -> None: + dema = algorithms.DoubleExponentialMovingAverage(alpha) + result = dema.update(new_value, timedelta(seconds=1)) + assert result == expected + + +# Additional test functions can be added here as needed. diff --git a/tests/test_backwards_compatibility.py b/tests/test_backwards_compatibility.py new file mode 100644 index 00000000..204a6749 --- /dev/null +++ b/tests/test_backwards_compatibility.py @@ -0,0 +1,17 @@ +import time + +import progressbar + + +def test_progressbar_1_widgets() -> None: + widgets = [ + progressbar.AdaptiveETA(format='Time left: %s'), + progressbar.Timer(format='Time passed: %s'), + progressbar.Bar(), + ] + + bar = progressbar.ProgressBar(widgets=widgets, max_value=100).start() + + for i in range(1, 101): + bar.update(i) + time.sleep(0.1) diff --git a/tests/test_color.py b/tests/test_color.py new file mode 100644 index 00000000..90b9b1ba --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import os +from typing import ClassVar + +import pytest + +import progressbar +import progressbar.terminal +from progressbar import env, terminal, widgets +from progressbar.terminal import Colors, apply_colors, colors + +ENVIRONMENT_VARIABLES = [ + 'PROGRESSBAR_ENABLE_COLORS', + 'FORCE_COLOR', + 'COLORTERM', + 'TERM', + 'JUPYTER_COLUMNS', + 'JUPYTER_LINES', + 'JPY_PARENT_PID', +] + + +@pytest.fixture(autouse=True) +def clear_env(monkeypatch: pytest.MonkeyPatch) -> None: + # Clear all environment variables that might affect the tests + for variable in ENVIRONMENT_VARIABLES: + monkeypatch.delenv(variable, raising=False) + + monkeypatch.setattr(env, 'JUPYTER', False) + + +@pytest.mark.parametrize( + 'variable', + [ + 'PROGRESSBAR_ENABLE_COLORS', + 'FORCE_COLOR', + ], +) +def test_color_environment_variables( + monkeypatch: pytest.MonkeyPatch, + variable: str, +) -> None: + if os.name == 'nt': + # Windows has special handling so we need to disable that to make the + # tests work properly + monkeypatch.setattr(os, 'name', 'posix') + + monkeypatch.setattr( + env, + 'COLOR_SUPPORT', + env.ColorSupport.XTERM_256, + ) + + monkeypatch.setenv(variable, 'true') + bar = progressbar.ProgressBar() + assert not env.is_ansi_terminal(bar.fd) + assert not bar.is_ansi_terminal + assert bar.enable_colors + + monkeypatch.setenv(variable, 'false') + bar = progressbar.ProgressBar() + assert not bar.enable_colors + + monkeypatch.setenv(variable, '') + bar = progressbar.ProgressBar() + assert not bar.enable_colors + + +@pytest.mark.parametrize( + 'variable', + [ + 'FORCE_COLOR', + 'PROGRESSBAR_ENABLE_COLORS', + 'COLORTERM', + 'TERM', + ], +) +@pytest.mark.parametrize( + 'value', + [ + '', + 'truecolor', + '24bit', + '256', + 'xterm-256', + 'xterm', + ], +) +def test_color_support_from_env(monkeypatch, variable, value) -> None: + if os.name == 'nt': + # Windows has special handling so we need to disable that to make the + # tests work properly + monkeypatch.setattr(os, 'name', 'posix') + + monkeypatch.setenv(variable, value) + env.ColorSupport.from_env() + + +@pytest.mark.parametrize( + 'variable', + [ + 'JUPYTER_COLUMNS', + 'JUPYTER_LINES', + ], +) +def test_color_support_from_env_jupyter(monkeypatch, variable) -> None: + monkeypatch.setattr(env, 'JUPYTER', True) + assert env.ColorSupport.from_env() == env.ColorSupport.XTERM_TRUECOLOR + + # Sanity check + monkeypatch.setattr(env, 'JUPYTER', False) + if os.name == 'nt': + assert env.ColorSupport.from_env() == env.ColorSupport.WINDOWS + else: + assert env.ColorSupport.from_env() == env.ColorSupport.NONE + + +def test_enable_colors_flags() -> None: + bar = progressbar.ProgressBar(enable_colors=True) + assert bar.enable_colors + + bar = progressbar.ProgressBar(enable_colors=False) + assert not bar.enable_colors + + bar = progressbar.ProgressBar( + enable_colors=env.ColorSupport.XTERM_TRUECOLOR, + ) + assert bar.enable_colors + + with pytest.raises(ValueError): + progressbar.ProgressBar(enable_colors=12345) + + +class _TestFixedColorSupport(progressbar.widgets.WidgetBase): + _fixed_colors: ClassVar[widgets.TFixedColors] = widgets.TFixedColors( + fg_none=progressbar.widgets.colors.yellow, + bg_none=None, + ) + + def __call__(self, *args, **kwargs) -> None: + pass + + +class _TestFixedGradientSupport(progressbar.widgets.WidgetBase): + _gradient_colors: ClassVar[widgets.TGradientColors] = ( + widgets.TGradientColors( + fg=progressbar.widgets.colors.gradient, + bg=None, + ) + ) + + def __call__(self, *args, **kwargs) -> None: + pass + + +@pytest.mark.parametrize( + 'widget', + [ + progressbar.Percentage, + progressbar.SimpleProgress, + _TestFixedColorSupport, + _TestFixedGradientSupport, + ], +) +def test_color_widgets(widget) -> None: + assert widget().uses_colors + print(f'{widget} has colors? {widget.uses_colors}') + + +def test_color_gradient() -> None: + gradient = terminal.ColorGradient(colors.red) + assert gradient.get_color(0) == gradient.get_color(-1) + assert gradient.get_color(1) == gradient.get_color(2) + + assert gradient.get_color(0.5) == colors.red + + gradient = terminal.ColorGradient(colors.red, colors.yellow) + assert gradient.get_color(0) == colors.red + assert gradient.get_color(1) == colors.yellow + assert gradient.get_color(0.5) != colors.red + assert gradient.get_color(0.5) != colors.yellow + + gradient = terminal.ColorGradient( + colors.red, + colors.yellow, + interpolate=False, + ) + assert gradient.get_color(0) == colors.red + assert gradient.get_color(1) == colors.yellow + assert gradient.get_color(0.5) == colors.red + + +@pytest.mark.parametrize( + 'widget', + [ + progressbar.Counter, + ], +) +def test_no_color_widgets(widget) -> None: + assert not widget().uses_colors + print(f'{widget} has colors? {widget.uses_colors}') + + assert widget( + fixed_colors=_TestFixedColorSupport._fixed_colors, + ).uses_colors + assert widget( + gradient_colors=_TestFixedGradientSupport._gradient_colors, + ).uses_colors + + +def test_colors(monkeypatch) -> None: + for colors_ in Colors.by_rgb.values(): + for color in colors_: + rgb = color.rgb + assert rgb.rgb + assert rgb.hex + assert rgb.to_ansi_16 is not None + assert rgb.to_ansi_256 is not None + assert rgb.to_windows is not None + + with monkeypatch.context() as context: + context.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.XTERM) + assert color.underline + context.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.WINDOWS) + assert color.underline + + assert color.fg + assert color.bg + assert str(color) + assert str(rgb) + assert color('test') + + +def test_color() -> None: + color = colors.red + if os.name != 'nt': + assert color('x') == color.fg('x') != 'x' + assert color.fg('x') != color.bg('x') != 'x' + assert color.fg('x') != color.underline('x') != 'x' + # Color hashes are based on the RGB value + assert hash(color) == hash(terminal.Color(color.rgb, None, None, None)) + Colors.register(color.rgb) + + +@pytest.mark.parametrize( + 'rgb,hls', + [ + (terminal.RGB(0, 0, 0), terminal.HSL(0, 0, 0)), + (terminal.RGB(255, 255, 255), terminal.HSL(0, 0, 100)), + (terminal.RGB(255, 0, 0), terminal.HSL(0, 100, 50)), + (terminal.RGB(0, 255, 0), terminal.HSL(120, 100, 50)), + (terminal.RGB(0, 0, 255), terminal.HSL(240, 100, 50)), + (terminal.RGB(255, 255, 0), terminal.HSL(60, 100, 50)), + (terminal.RGB(0, 255, 255), terminal.HSL(180, 100, 50)), + (terminal.RGB(255, 0, 255), terminal.HSL(300, 100, 50)), + (terminal.RGB(128, 128, 128), terminal.HSL(0, 0, 50)), + (terminal.RGB(128, 0, 0), terminal.HSL(0, 100, 25)), + (terminal.RGB(128, 128, 0), terminal.HSL(60, 100, 25)), + (terminal.RGB(0, 128, 0), terminal.HSL(120, 100, 25)), + (terminal.RGB(128, 0, 128), terminal.HSL(300, 100, 25)), + (terminal.RGB(0, 128, 128), terminal.HSL(180, 100, 25)), + (terminal.RGB(0, 0, 128), terminal.HSL(240, 100, 25)), + (terminal.RGB(192, 192, 192), terminal.HSL(0, 0, 75)), + ], +) +def test_rgb_to_hls(rgb, hls) -> None: + assert terminal.HSL.from_rgb(rgb) == hls + + +@pytest.mark.parametrize( + 'text, fg, bg, fg_none, bg_none, percentage, expected', + [ + ('test', None, None, None, None, None, 'test'), + ('test', None, None, None, None, 1, 'test'), + ( + 'test', + None, + None, + None, + colors.red, + None, + '\x1b[48;5;9mtest\x1b[49m', + ), + ( + 'test', + None, + colors.green, + None, + colors.red, + None, + '\x1b[48;5;9mtest\x1b[49m', + ), + ('test', None, colors.red, None, None, 1, '\x1b[48;5;9mtest\x1b[49m'), + ('test', None, colors.red, None, None, None, 'test'), + ( + 'test', + colors.green, + None, + colors.red, + None, + None, + '\x1b[38;5;9mtest\x1b[39m', + ), + ( + 'test', + colors.green, + colors.red, + None, + None, + 1, + '\x1b[48;5;9m\x1b[38;5;2mtest\x1b[39m\x1b[49m', + ), + ('test', colors.red, None, None, None, 1, '\x1b[38;5;9mtest\x1b[39m'), + ('test', colors.red, None, None, None, None, 'test'), + ('test', colors.red, colors.red, None, None, None, 'test'), + ( + 'test', + colors.red, + colors.yellow, + None, + None, + 1, + '\x1b[48;5;11m\x1b[38;5;9mtest\x1b[39m\x1b[49m', + ), + ( + 'test', + colors.red, + colors.yellow, + None, + None, + 1, + '\x1b[48;5;11m\x1b[38;5;9mtest\x1b[39m\x1b[49m', + ), + ], +) +def test_apply_colors( + text: str, + fg, + bg, + fg_none, + bg_none, + percentage: float | None, + expected, + monkeypatch, +) -> None: + monkeypatch.setattr( + env, + 'COLOR_SUPPORT', + env.ColorSupport.XTERM_256, + ) + assert ( + apply_colors( + text, + fg=fg, + bg=bg, + fg_none=fg_none, + bg_none=bg_none, + percentage=percentage, + ) + == expected + ) + + +def test_windows_colors(monkeypatch) -> None: + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.WINDOWS) + assert ( + apply_colors( + 'test', + fg=colors.red, + bg=colors.red, + fg_none=colors.red, + bg_none=colors.red, + percentage=1, + ) + == 'test' + ) + colors.red.underline('test') + + +def test_ansi_color(monkeypatch) -> None: + color = progressbar.terminal.Color( + colors.red.rgb, + colors.red.hls, + 'red-ansi', + None, + ) + + for color_support in { + env.ColorSupport.NONE, + env.ColorSupport.XTERM, + env.ColorSupport.XTERM_256, + env.ColorSupport.XTERM_TRUECOLOR, + }: + monkeypatch.setattr( + env, + 'COLOR_SUPPORT', + color_support, + ) + assert color.ansi is not None or color_support == env.ColorSupport.NONE + + +def test_sgr_call() -> None: + assert progressbar.terminal.encircled('test') == '\x1b[52mtest\x1b[54m' diff --git a/tests/test_custom_widgets.py b/tests/test_custom_widgets.py new file mode 100644 index 00000000..b0a272e4 --- /dev/null +++ b/tests/test_custom_widgets.py @@ -0,0 +1,95 @@ +import time + +import pytest + +import progressbar + + +class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): + "It's bigger between 45 and 80 percent" + + def update(self, pbar): + if 45 < pbar.percentage() < 80: + value = progressbar.FileTransferSpeed.update(self, pbar) + return f'Bigger Now {value}' + else: + return progressbar.FileTransferSpeed.update(self, pbar) + + +def test_crazy_file_transfer_speed_widget() -> None: + widgets = [ + # CrazyFileTransferSpeed(), + ' <<<', + progressbar.Bar(), + '>>> ', + progressbar.Percentage(), + ' ', + progressbar.ETA(), + ] + + p = progressbar.ProgressBar(widgets=widgets, max_value=1000) + # maybe do something + p.start() + for i in range(0, 200, 5): + # do something + time.sleep(0.1) + p.update(i + 1) + p.finish() + + +def test_variable_widget_widget() -> None: + widgets = [ + ' [', + progressbar.Timer(), + '] ', + progressbar.Bar(), + ' (', + progressbar.ETA(), + ') ', + progressbar.Variable('loss'), + progressbar.Variable('text'), + progressbar.Variable('error', precision=None), + progressbar.Variable('missing'), + progressbar.Variable('predefined'), + ] + + p = progressbar.ProgressBar( + widgets=widgets, + max_value=1000, + variables=dict(predefined='predefined'), + ) + p.start() + print('time', time, time.sleep) + for i in range(0, 200, 5): + time.sleep(0.1) + p.update(i + 1, loss=0.5, text='spam', error=1) + + i += 1 + p.update(i, text=None) + i += 1 + p.update(i, text=False) + i += 1 + p.update(i, text=True, error='a') + with pytest.raises(TypeError): + p.update(i, non_existing_variable='error!') + p.finish() + + +def test_format_custom_text_widget() -> None: + widget = progressbar.FormatCustomText( + 'Spam: %(spam).1f kg, eggs: %(eggs)d', + dict( + spam=0.25, + eggs=3, + ), + ) + + bar = progressbar.ProgressBar( + widgets=[ + widget, + ], + ) + + for i in bar(range(5)): + widget.update_mapping(eggs=i * 2) + assert widget.mapping['eggs'] == bar.widgets[0].mapping['eggs'] diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 00000000..43071632 --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,25 @@ +import pytest + +import progressbar + + +@pytest.mark.parametrize( + 'value,expected', + [ + (None, ' 0.0 B'), + (1, ' 1.0 B'), + (2**10 - 1, '1023.0 B'), + (2**10 + 0, ' 1.0 KiB'), + (2**20, ' 1.0 MiB'), + (2**30, ' 1.0 GiB'), + (2**40, ' 1.0 TiB'), + (2**50, ' 1.0 PiB'), + (2**60, ' 1.0 EiB'), + (2**70, ' 1.0 ZiB'), + (2**80, ' 1.0 YiB'), + (2**90, '1024.0 YiB'), + ], +) +def test_data_size(value, expected) -> None: + widget = progressbar.DataSize() + assert widget(None, dict(value=value)) == expected diff --git a/tests/test_data_transfer_bar.py b/tests/test_data_transfer_bar.py new file mode 100644 index 00000000..7e5cfcce --- /dev/null +++ b/tests/test_data_transfer_bar.py @@ -0,0 +1,16 @@ +import progressbar +from progressbar import DataTransferBar + + +def test_known_length() -> None: + dtb = DataTransferBar().start(max_value=50) + for i in range(50): + dtb.update(i) + dtb.finish() + + +def test_unknown_length() -> None: + dtb = DataTransferBar().start(max_value=progressbar.UnknownLength) + for i in range(50): + dtb.update(i) + dtb.finish() diff --git a/tests/test_dill_pickle.py b/tests/test_dill_pickle.py new file mode 100644 index 00000000..37312452 --- /dev/null +++ b/tests/test_dill_pickle.py @@ -0,0 +1,15 @@ +import dill # type: ignore + +import progressbar + + +def test_dill() -> None: + bar = progressbar.ProgressBar() + assert bar._started is False + assert bar._finished is False + + assert dill.pickles(bar) is False + + assert bar._started is False + # Should be false because it never should have started/initialized + assert bar._finished is False diff --git a/tests/test_empty.py b/tests/test_empty.py new file mode 100644 index 00000000..f326e384 --- /dev/null +++ b/tests/test_empty.py @@ -0,0 +1,11 @@ +import progressbar + + +def test_empty_list() -> None: + for x in progressbar.ProgressBar()([]): + print(x) + + +def test_empty_iterator() -> None: + for x in progressbar.ProgressBar(max_value=0)(iter([])): + print(x) diff --git a/tests/test_end.py b/tests/test_end.py new file mode 100644 index 00000000..e7c69e3e --- /dev/null +++ b/tests/test_end.py @@ -0,0 +1,56 @@ +import pytest + +import progressbar + + +@pytest.fixture(autouse=True) +def large_interval(monkeypatch) -> None: + # Remove the update limit for tests by default + monkeypatch.setattr( + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + 0.1, + ) + + +def test_end() -> None: + m = 24514315 + p = progressbar.ProgressBar( + widgets=[progressbar.Percentage(), progressbar.Bar()], + max_value=m, + ) + + for x in range(0, m, 8192): + p.update(x) + + data = p.data() + assert data['percentage'] < 100.0 + + p.finish() + + data = p.data() + assert data['percentage'] >= 100.0 + + assert p.value == m + + +def test_end_100(monkeypatch) -> None: + assert progressbar.ProgressBar._MINIMUM_UPDATE_INTERVAL == 0.1 + p = progressbar.ProgressBar( + widgets=[progressbar.Percentage(), progressbar.Bar()], + max_value=103, + ) + + for x in range(102): + p.update(x) + + data = p.data() + import pprint + + pprint.pprint(data) + assert data['percentage'] < 100.0 + + p.finish() + + data = p.data() + assert data['percentage'] >= 100.0 diff --git a/tests/test_failure.py b/tests/test_failure.py new file mode 100644 index 00000000..5953284b --- /dev/null +++ b/tests/test_failure.py @@ -0,0 +1,140 @@ +import logging +import time + +import pytest + +import progressbar + + +def test_missing_format_values(caplog) -> None: + caplog.set_level(logging.CRITICAL, logger='progressbar.widgets') + with pytest.raises(KeyError): + p = progressbar.ProgressBar( + widgets=[progressbar.widgets.FormatLabel('%(x)s')], + ) + p.update(5) + + +def test_max_smaller_than_min() -> None: + with pytest.raises(ValueError): + progressbar.ProgressBar(min_value=10, max_value=5) + + +def test_no_max_value() -> None: + """Looping up to 5 without max_value? No problem""" + p = progressbar.ProgressBar() + p.start() + for i in range(5): + time.sleep(1) + p.update(i) + + +def test_correct_max_value() -> None: + """Looping up to 5 when max_value is 10? No problem""" + p = progressbar.ProgressBar(max_value=10) + for i in range(5): + time.sleep(1) + p.update(i) + + +def test_minus_max_value() -> None: + """negative max_value, shouldn't work""" + p = progressbar.ProgressBar(min_value=-2, max_value=-1) + + with pytest.raises(ValueError): + p.update(-1) + + +def test_zero_max_value() -> None: + """max_value of zero, it could happen""" + p = progressbar.ProgressBar(max_value=0) + + p.update(0) + with pytest.raises(ValueError): + p.update(1) + + +def test_one_max_value() -> None: + """max_value of one, another corner case""" + p = progressbar.ProgressBar(max_value=1) + + p.update(0) + p.update(0) + p.update(1) + with pytest.raises(ValueError): + p.update(2) + + +def test_changing_max_value() -> None: + """Changing max_value? No problem""" + p = progressbar.ProgressBar(max_value=10)(range(20), max_value=20) + for _i in p: + time.sleep(1) + + +def test_backwards() -> None: + """progressbar going backwards""" + p = progressbar.ProgressBar(max_value=1) + + p.update(1) + p.update(0) + + +def test_incorrect_max_value() -> None: + """Looping up to 10 when max_value is 5? This is madness!""" + p = progressbar.ProgressBar(max_value=5) + for i in range(5): + time.sleep(1) + p.update(i) + + with pytest.raises(ValueError): + for i in range(5, 10): + time.sleep(1) + p.update(i) + + +def test_deprecated_maxval() -> None: + with pytest.warns(DeprecationWarning): + progressbar.ProgressBar(maxval=5) + + +def test_deprecated_poll() -> None: + with pytest.warns(DeprecationWarning): + progressbar.ProgressBar(poll=5) + + +def test_deprecated_currval() -> None: + with pytest.warns(DeprecationWarning): + bar = progressbar.ProgressBar(max_value=5) + bar.update(2) + assert bar.currval == 2 + + +def test_unexpected_update_keyword_arg() -> None: + p = progressbar.ProgressBar(max_value=10) + with pytest.raises(TypeError): + for i in range(10): + time.sleep(1) + p.update(i, foo=10) + + +def test_variable_not_str() -> None: + with pytest.raises(TypeError): + progressbar.Variable(1) + + +def test_variable_too_many_strs() -> None: + with pytest.raises(ValueError): + progressbar.Variable('too long') + + +def test_negative_value() -> None: + bar = progressbar.ProgressBar(max_value=10) + with pytest.raises(ValueError): + bar.update(value=-1) + + +def test_increment() -> None: + bar = progressbar.ProgressBar(max_value=10) + bar.increment() + del bar diff --git a/tests/test_flush.py b/tests/test_flush.py new file mode 100644 index 00000000..edade1c3 --- /dev/null +++ b/tests/test_flush.py @@ -0,0 +1,17 @@ +import time + +import progressbar + + +def test_flush() -> None: + """Left justify using the terminal width""" + p = progressbar.ProgressBar(poll_interval=0.001) + p.print('hello') + + for i in range(10): + print('pre-updates', p.updates) + p.update(i) + print('need update?', p._needs_update()) + if i > 5: + time.sleep(0.1) + print('post-updates', p.updates) diff --git a/tests/test_iterators.py b/tests/test_iterators.py new file mode 100644 index 00000000..d4474e7e --- /dev/null +++ b/tests/test_iterators.py @@ -0,0 +1,61 @@ +import time + +import pytest + +import progressbar + + +def test_list() -> None: + """Progressbar can guess max_value automatically.""" + p = progressbar.ProgressBar() + for _i in p(range(10)): + time.sleep(0.001) + + +def test_iterator_with_max_value() -> None: + """Progressbar can't guess max_value.""" + p = progressbar.ProgressBar(max_value=10) + for _i in p(iter(range(10))): + time.sleep(0.001) + + +def test_iterator_without_max_value_error() -> None: + """Progressbar can't guess max_value.""" + p = progressbar.ProgressBar() + + for _i in p(iter(range(10))): + time.sleep(0.001) + + assert p.max_value is progressbar.UnknownLength + + +def test_iterator_without_max_value() -> None: + """Progressbar can't guess max_value.""" + p = progressbar.ProgressBar( + widgets=[ + progressbar.AnimatedMarker(), + progressbar.FormatLabel('%(value)d'), + progressbar.BouncingBar(), + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ], + ) + for _i in p(iter(range(10))): + time.sleep(0.001) + + +def test_iterator_with_incorrect_max_value() -> None: + """Progressbar can't guess max_value.""" + p = progressbar.ProgressBar(max_value=10) + with pytest.raises(ValueError): + for _i in p(iter(range(20))): + time.sleep(0.001) + + +def test_adding_value() -> None: + p = progressbar.ProgressBar(max_value=10) + p.start() + p.update(5) + p += 2 + p.increment(2) + with pytest.raises(ValueError): + p += 5 diff --git a/tests/test_job_status.py b/tests/test_job_status.py new file mode 100644 index 00000000..778b6ce3 --- /dev/null +++ b/tests/test_job_status.py @@ -0,0 +1,22 @@ +import time + +import pytest + +import progressbar + + +@pytest.mark.parametrize( + 'status', + [ + True, + False, + None, + ], +) +def test_status(status) -> None: + with progressbar.ProgressBar( + widgets=[progressbar.widgets.JobStatusBar('status')], + ) as bar: + for _ in range(5): + bar.increment(status=status, force=True) + time.sleep(0.1) diff --git a/tests/test_large_values.py b/tests/test_large_values.py new file mode 100644 index 00000000..2e7ad72f --- /dev/null +++ b/tests/test_large_values.py @@ -0,0 +1,17 @@ +import time + +import progressbar + + +def test_large_max_value() -> None: + with progressbar.ProgressBar(max_value=1e10) as bar: + for i in range(10): + bar.update(i) + time.sleep(0.1) + + +def test_value_beyond_max_value() -> None: + with progressbar.ProgressBar(max_value=10, max_error=False) as bar: + for i in range(20): + bar.update(i) + time.sleep(0.01) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 00000000..b0725afe --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,13 @@ +from progressbar import __about__ + + +def test_about() -> None: + assert __about__.__title__ + assert __about__.__package_name__ + assert __about__.__author__ + assert __about__.__description__ + assert __about__.__email__ + assert __about__.__version__ + assert __about__.__license__ + assert __about__.__copyright__ + assert __about__.__url__ diff --git a/tests/test_monitor_progress.py b/tests/test_monitor_progress.py new file mode 100644 index 00000000..4f99df90 --- /dev/null +++ b/tests/test_monitor_progress.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +# fmt: off +import os +import pprint + +import progressbar + +pytest_plugins = 'pytester' + +SCRIPT = """ +import sys +sys.path.append({progressbar_path!r}) +import time +import timeit +import freezegun +import progressbar + + +with freezegun.freeze_time() as fake_time: + timeit.default_timer = time.time + with progressbar.ProgressBar(widgets={widgets}, **{kwargs!r}) as bar: + bar._MINIMUM_UPDATE_INTERVAL = 1e-9 + for i in bar({items}): + {loop_code} +""" + + +def _non_empty_lines(lines): + return [line for line in lines if line.strip()] + + +def _create_script( + widgets=None, + items: list[int] | None=None, + loop_code: str='fake_time.tick(1)', + term_width: int=60, + **kwargs, +) -> str: + if items is None: + items = list(range(9)) + kwargs['term_width'] = term_width + + # Reindent the loop code + indent = '\n ' + loop_code = loop_code.strip('\n').split('\n') + dedent = len(loop_code[0]) - len(loop_code[0].lstrip()) + for i, line in enumerate(loop_code): + loop_code[i] = line[dedent:] + + script = SCRIPT.format( + items=items, + widgets=widgets, + kwargs=kwargs, + loop_code=indent.join(loop_code), + progressbar_path=os.path.dirname( + os.path.dirname(progressbar.__file__), + ), + ) + print('# Script:') + print('#' * 78) + print(script) + print('#' * 78) + + return script + + +def test_list_example(testdir) -> None: + """Run the simple example code in a python subprocess and then compare its + stderr to what we expect to see from it. We run it in a subprocess to + best capture its stderr. We expect to see match_lines in order in the + output. This test is just a sanity check to ensure that the progress + bar progresses from 1 to 10, it does not make sure that the""" + + result = testdir.runpython( + testdir.makepyfile( + _create_script( + term_width=65, + ), + ), + ) + result.stderr.lines = [ + line.rstrip() for line in _non_empty_lines(result.stderr.lines) + ] + pprint.pprint(result.stderr.lines, width=70) + result.stderr.fnmatch_lines([ + ' 0% (0 of 9) | | Elapsed Time: ?:00:00 ETA: --:--:--', + ' 11% (1 of 9) |# | Elapsed Time: ?:00:01 ETA: ?:00:08', + ' 22% (2 of 9) |## | Elapsed Time: ?:00:02 ETA: ?:00:07', + ' 33% (3 of 9) |#### | Elapsed Time: ?:00:03 ETA: ?:00:06', + ' 44% (4 of 9) |##### | Elapsed Time: ?:00:04 ETA: ?:00:05', + ' 55% (5 of 9) |###### | Elapsed Time: ?:00:05 ETA: ?:00:04', + ' 66% (6 of 9) |######## | Elapsed Time: ?:00:06 ETA: ?:00:03', + ' 77% (7 of 9) |######### | Elapsed Time: ?:00:07 ETA: ?:00:02', + ' 88% (8 of 9) |########## | Elapsed Time: ?:00:08 ETA: ?:00:01', + '100% (9 of 9) |############| Elapsed Time: ?:00:09 Time: ?:00:09', + ]) + + +def test_generator_example(testdir) -> None: + """Run the simple example code in a python subprocess and then compare its + stderr to what we expect to see from it. We run it in a subprocess to + best capture its stderr. We expect to see match_lines in order in the + output. This test is just a sanity check to ensure that the progress + bar progresses from 1 to 10, it does not make sure that the""" + result = testdir.runpython( + testdir.makepyfile( + _create_script( + items='iter(range(9))', + ), + ), + ) + result.stderr.lines = _non_empty_lines(result.stderr.lines) + pprint.pprint(result.stderr.lines, width=70) + + lines = [ + r'[/\\|\-]\s+\|\s*#\s*\| %(i)d Elapsed Time: \d:00:%(i)02d' % dict(i=i) + for i in range(9) + ] + result.stderr.re_match_lines(lines) + + +def test_rapid_updates(testdir) -> None: + """Run some example code that updates 10 times, then sleeps .1 seconds, + this is meant to test that the progressbar progresses normally with + this sample code, since there were issues with it in the past""" + + result = testdir.runpython( + testdir.makepyfile( + _create_script( + term_width=60, + items=list(range(10)), + loop_code=""" + if i < 5: + fake_time.tick(1) + else: + fake_time.tick(2) + """, + ), + ), + ) + result.stderr.lines = _non_empty_lines(result.stderr.lines) + pprint.pprint(result.stderr.lines, width=70) + result.stderr.fnmatch_lines( + [ + ' 0% (0 of 10) | | Elapsed Time: 0:00:00 ETA: --:--:--', + ' 10% (1 of 10) | | Elapsed Time: 0:00:01 ETA: 0:00:09', + ' 20% (2 of 10) |# | Elapsed Time: 0:00:02 ETA: 0:00:08', + ' 30% (3 of 10) |# | Elapsed Time: 0:00:03 ETA: 0:00:07', + ' 40% (4 of 10) |## | Elapsed Time: 0:00:04 ETA: 0:00:06', + ' 50% (5 of 10) |### | Elapsed Time: 0:00:05 ETA: 0:00:05', + ' 60% (6 of 10) |### | Elapsed Time: 0:00:07 ETA: 0:00:04', + ' 70% (7 of 10) |#### | Elapsed Time: 0:00:09 ETA: 0:00:03', + ' 80% (8 of 10) |#### | Elapsed Time: 0:00:11 ETA: 0:00:02', + ' 90% (9 of 10) |##### | Elapsed Time: 0:00:13 ETA: 0:00:01', + '100% (10 of 10) |#####| Elapsed Time: 0:00:15 Time: 0:00:15', + ], + ) + + +def test_non_timed(testdir) -> None: + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.Percentage(), progressbar.Bar()]', + items=list(range(5)), + ), + ), + ) + result.stderr.lines = _non_empty_lines(result.stderr.lines) + pprint.pprint(result.stderr.lines, width=70) + result.stderr.fnmatch_lines( + [ + ' 0%| |', + ' 20%|########## |', + ' 40%|##################### |', + ' 60%|################################ |', + ' 80%|########################################### |', + '100%|######################################################|', + ], + ) + + +def test_line_breaks(testdir) -> None: + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.Percentage(), progressbar.Bar()]', + line_breaks=True, + items=list(range(5)), + ), + ), + ) + pprint.pprint(result.stderr.str(), width=70) + assert result.stderr.str() == '\n'.join( + ( + ' 0%| |', + ' 20%|########## |', + ' 40%|##################### |', + ' 60%|################################ |', + ' 80%|########################################### |', + '100%|######################################################|', + '100%|######################################################|', + ), + ) + + +def test_no_line_breaks(testdir) -> None: + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.Percentage(), progressbar.Bar()]', + line_breaks=False, + items=list(range(5)), + ), + ), + ) + pprint.pprint(result.stderr.lines, width=70) + assert result.stderr.lines == [ + '', + ' 0%| |', + ' 20%|########## |', + ' 40%|##################### |', + ' 60%|################################ |', + ' 80%|########################################### |', + '100%|######################################################|', + '', + '100%|######################################################|', + ] + + +def test_percentage_label_bar(testdir) -> None: + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.PercentageLabelBar()]', + line_breaks=False, + items=list(range(5)), + ), + ), + ) + pprint.pprint(result.stderr.lines, width=70) + assert result.stderr.lines == [ + '', + '| 0% |', + '|########### 20% |', + '|####################### 40% |', + '|###########################60%#### |', + '|###########################80%################ |', + '|###########################100%###########################|', + '', + '|###########################100%###########################|', + ] + + +def test_granular_bar(testdir) -> None: + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.GranularBar(markers=" .oO")]', + line_breaks=False, + items=list(range(5)), + ), + ), + ) + pprint.pprint(result.stderr.lines, width=70) + assert result.stderr.lines == [ + '', + '| |', + '|OOOOOOOOOOO. |', + '|OOOOOOOOOOOOOOOOOOOOOOO |', + '|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOo |', + '|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO. |', + '|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO|', + '', + '|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO|', + ] + + +def test_colors(testdir) -> None: + kwargs = dict( + items=range(1), + widgets=['\033[92mgreen\033[0m'], + ) + + result = testdir.runpython( + testdir.makepyfile(_create_script(enable_colors=True, **kwargs)), + ) + pprint.pprint(result.stderr.lines, width=70) + assert result.stderr.lines == ['\x1b[92mgreen\x1b[0m'] * 3 + + result = testdir.runpython( + testdir.makepyfile(_create_script(enable_colors=False, **kwargs)), + ) + pprint.pprint(result.stderr.lines, width=70) + assert result.stderr.lines == ['green'] * 3 diff --git a/tests/test_multibar.py b/tests/test_multibar.py new file mode 100644 index 00000000..84484200 --- /dev/null +++ b/tests/test_multibar.py @@ -0,0 +1,249 @@ +import random +import threading +import time + +import pytest + +import progressbar + +N = 10 +BARS = 3 +SLEEP = 0.002 + + +def test_multi_progress_bar_out_of_range() -> None: + widgets = [ + progressbar.MultiProgressBar('multivalues'), + ] + + bar = progressbar.ProgressBar(widgets=widgets, max_value=10) + with pytest.raises(ValueError): + bar.update(multivalues=[123]) + + with pytest.raises(ValueError): + bar.update(multivalues=[-1]) + + +def test_multibar() -> None: + multibar = progressbar.MultiBar( + sort_keyfunc=lambda bar: bar.label, + remove_finished=0.005, + ) + multibar.show_initial = False + multibar.render(force=True) + multibar.show_initial = True + multibar.render(force=True) + multibar.start() + + multibar.append_label = False + multibar.prepend_label = True + + # Test handling of progressbars that don't call the super constructors + bar = progressbar.ProgressBar(max_value=N) + bar.index = -1 + multibar['x'] = bar + bar.start() + # Test twice for other code paths + multibar['x'] = bar + multibar._label_bar(bar) + multibar._label_bar(bar) + bar.finish() + del multibar['x'] + + multibar.prepend_label = False + multibar.append_label = True + + append_bar = progressbar.ProgressBar(max_value=N) + append_bar.start() + multibar._label_bar(append_bar) + multibar['append'] = append_bar + multibar.render(force=True) + + def do_something(bar): + for j in bar(range(N)): + time.sleep(0.01) + bar.update(j) + + for i in range(BARS): + thread = threading.Thread( + target=do_something, + args=(multibar[f'bar {i}'],), + ) + thread.start() + + for bar in list(multibar.values()): + for j in range(N): + bar.update(j) + time.sleep(SLEEP) + + multibar.render(force=True) + + multibar.remove_finished = False + multibar.show_finished = False + append_bar.finish() + multibar.render(force=True) + + multibar.join(0.1) + multibar.stop(0.1) + + +@pytest.mark.parametrize( + 'sort_key', + [ + None, + 'index', + 'label', + 'value', + 'percentage', + progressbar.SortKey.CREATED, + progressbar.SortKey.LABEL, + progressbar.SortKey.VALUE, + progressbar.SortKey.PERCENTAGE, + ], +) +def test_multibar_sorting(sort_key) -> None: + with progressbar.MultiBar() as multibar: + for i in range(BARS): + label = f'bar {i}' + multibar[label] = progressbar.ProgressBar(max_value=N) + + for bar in multibar.values(): + for _j in bar(range(N)): + assert bar.started() + time.sleep(SLEEP) + + for bar in multibar.values(): + assert bar.finished() + + +def test_offset_bar() -> None: + with progressbar.ProgressBar(line_offset=2) as bar: + for i in range(N): + bar.update(i) + + +def test_multibar_show_finished() -> None: + multibar = progressbar.MultiBar(show_finished=True) + multibar['bar'] = progressbar.ProgressBar(max_value=N) + multibar.render(force=True) + with progressbar.MultiBar(show_finished=False) as multibar: + multibar.finished_format = 'finished: {label}' + + for i in range(3): + multibar[f'bar {i}'] = progressbar.ProgressBar(max_value=N) + + for bar in multibar.values(): + for i in range(N): + bar.update(i) + time.sleep(SLEEP) + + multibar.render(force=True) + + +def test_multibar_show_initial() -> None: + multibar = progressbar.MultiBar(show_initial=False) + multibar['bar'] = progressbar.ProgressBar(max_value=N) + multibar.render(force=True) + + +def test_multibar_empty_key() -> None: + multibar = progressbar.MultiBar() + multibar[''] = progressbar.ProgressBar(max_value=N) + + for name in multibar: + assert name == '' + bar = multibar[name] + bar.update(1) + + multibar.render(force=True) + + +def test_multibar_print() -> None: + bars = 5 + n = 10 + + def print_sometimes(bar, probability): + 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() < probability: + 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}'] + bar.max_error = False + # Create a thread and pass the progressbar + # Print never, sometimes and always + threading.Thread(target=print_sometimes, args=(bar, 0)).start() + threading.Thread(target=print_sometimes, args=(bar, 0.5)).start() + threading.Thread(target=print_sometimes, args=(bar, 1)).start() + + for i in range(5): + multibar.print(f'{i}', flush=False) + + multibar.update(force=True, flush=False) + multibar.update(force=True, flush=True) + + +def test_multibar_no_format() -> None: + with progressbar.MultiBar( + initial_format=None, finished_format=None + ) as multibar: + bar = multibar['a'] + + for i in bar(range(5)): + bar.print(i) + + +def test_multibar_finished() -> None: + multibar = progressbar.MultiBar(initial_format=None, finished_format=None) + bar = multibar['bar'] = progressbar.ProgressBar(max_value=5) + bar2 = multibar['bar2'] + multibar.render(force=True) + multibar.print('Hi') + multibar.render(force=True, flush=False) + + for i in range(6): + bar.update(i) + bar2.update(i) + + multibar.render(force=True) + + +def test_multibar_finished_format() -> None: + multibar = progressbar.MultiBar( + finished_format='Finished {label}', show_finished=True + ) + bar = multibar['bar'] = progressbar.ProgressBar(max_value=5) + bar2 = multibar['bar2'] + multibar.render(force=True) + multibar.print('Hi') + multibar.render(force=True, flush=False) + bar.start() + bar2.start() + multibar.render(force=True) + multibar.print('Hi') + multibar.render(force=True, flush=False) + + for i in range(6): + bar.update(i) + bar2.update(i) + + multibar.render(force=True) + + +def test_multibar_threads() -> None: + multibar = progressbar.MultiBar(finished_format=None, show_finished=True) + bar = multibar['bar'] = progressbar.ProgressBar(max_value=5) + multibar.start() + time.sleep(0.1) + bar.update(3) + time.sleep(0.1) + multibar.join() + bar.finish() + multibar.join() + multibar.render(force=True) diff --git a/tests/test_progressbar.py b/tests/test_progressbar.py index aa8d8596..eb79e66d 100644 --- a/tests/test_progressbar.py +++ b/tests/test_progressbar.py @@ -1,5 +1,78 @@ +import contextlib +import os +import time -def test_examples(): - from examples import examples - for example in examples: - example() +import original_examples # type: ignore +import pytest + +import progressbar + +# Import hack to allow for parallel Tox +try: + import examples +except ImportError: + import sys + + _project_dir: str = os.path.dirname(os.path.dirname(__file__)) + sys.path.append(_project_dir) + import examples + + sys.path.remove(_project_dir) + + +def test_examples(monkeypatch) -> None: + for example in examples.examples: + with contextlib.suppress(ValueError): + example() + + +@pytest.mark.filterwarnings('ignore:.*maxval.*:DeprecationWarning') +@pytest.mark.parametrize('example', original_examples.examples) +def test_original_examples(example, monkeypatch) -> None: + monkeypatch.setattr(progressbar.ProgressBar, '_MINIMUM_UPDATE_INTERVAL', 1) + monkeypatch.setattr(time, 'sleep', lambda t: None) + example() + + +@pytest.mark.parametrize('example', examples.examples) +def test_examples_nullbar(monkeypatch, example) -> None: + # Patch progressbar to use null bar instead of regular progress bar + monkeypatch.setattr(progressbar, 'ProgressBar', progressbar.NullBar) + assert progressbar.ProgressBar._MINIMUM_UPDATE_INTERVAL < 0.0001 + example() + + +def test_reuse() -> None: + bar = progressbar.ProgressBar() + bar.start() + for i in range(10): + bar.update(i) + bar.finish() + + bar.start(init=True) + for i in range(10): + bar.update(i) + bar.finish() + + bar.start(init=False) + for i in range(10): + bar.update(i) + bar.finish() + + +def test_dirty() -> None: + bar = progressbar.ProgressBar() + bar.start() + assert bar.started() + for i in range(10): + bar.update(i) + bar.finish(dirty=True) + assert bar.finished() + assert bar.started() + + +def test_negative_maximum() -> None: + with pytest.raises(ValueError), progressbar.ProgressBar( + max_value=-1 + ) as progress: + progress.start() diff --git a/tests/test_progressbar_command.py b/tests/test_progressbar_command.py new file mode 100644 index 00000000..3dd82d60 --- /dev/null +++ b/tests/test_progressbar_command.py @@ -0,0 +1,144 @@ +import io + +import pytest + +import progressbar.__main__ as main + + +def test_size_to_bytes() -> None: + assert main.size_to_bytes('1') == 1 + assert main.size_to_bytes('1k') == 1024 + assert main.size_to_bytes('1m') == 1048576 + assert main.size_to_bytes('1g') == 1073741824 + assert main.size_to_bytes('1p') == 1125899906842624 + + assert main.size_to_bytes('1024') == 1024 + assert main.size_to_bytes('1024k') == 1048576 + assert main.size_to_bytes('1024m') == 1073741824 + assert main.size_to_bytes('1024g') == 1099511627776 + assert main.size_to_bytes('1024p') == 1152921504606846976 + + +def test_filename_to_bytes(tmp_path) -> None: + file = tmp_path / 'test' + file.write_text('test') + assert main.size_to_bytes(f'@{file}') == 4 + + with pytest.raises(FileNotFoundError): + main.size_to_bytes(f'@{tmp_path / "nonexistent"}') + + +def test_create_argument_parser() -> None: + parser = main.create_argument_parser() + args = parser.parse_args( + [ + '-p', + '-t', + '-e', + '-r', + '-a', + '-b', + '-8', + '-T', + '-n', + '-q', + 'input', + '-o', + 'output', + ] + ) + assert args.progress is True + assert args.timer is True + assert args.eta is True + assert args.rate is True + assert args.average_rate is True + assert args.bytes is True + assert args.bits is True + assert args.buffer_percent is True + assert args.last_written is None + assert args.format is None + assert args.numeric is True + assert args.quiet is True + assert args.input == ['input'] + assert args.output == 'output' + + +def test_main_binary(capsys) -> None: + # Call the main function with different command line arguments + main.main( + [ + '-p', + '-t', + '-e', + '-r', + '-a', + '-b', + '-8', + '-T', + '-n', + '-q', + __file__, + ] + ) + + captured = capsys.readouterr() + assert 'test_main(capsys):' in captured.out + + +def test_main_lines(capsys) -> None: + # Call the main function with different command line arguments + main.main( + [ + '-p', + '-t', + '-e', + '-r', + '-a', + '-b', + '-8', + '-T', + '-n', + '-q', + '-l', + '-s', + f'@{__file__}', + __file__, + ] + ) + + captured = capsys.readouterr() + assert 'test_main(capsys):' in captured.out + + +class Input(io.StringIO): + buffer: io.BytesIO + + @classmethod + def create(cls, text: str) -> 'Input': + instance = cls(text) + instance.buffer = io.BytesIO(text.encode()) + return instance + + +def test_main_lines_output(monkeypatch, tmp_path) -> None: + text = 'my input' + monkeypatch.setattr('sys.stdin', Input.create(text)) + output_filename = tmp_path / 'output' + main.main(['-l', '-o', str(output_filename)]) + + assert output_filename.read_text() == text + + +def test_main_bytes_output(monkeypatch, tmp_path) -> None: + text = 'my input' + + monkeypatch.setattr('sys.stdin', Input.create(text)) + output_filename = tmp_path / 'output' + main.main(['-o', str(output_filename)]) + + assert output_filename.read_text() == f'{text}' + + +def test_missing_input(tmp_path) -> None: + with pytest.raises(SystemExit): + main.main([str(tmp_path / 'output')]) diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 00000000..2881fac0 --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,112 @@ +import time +from datetime import datetime, timedelta + +from python_utils.containers import SliceableDeque + +import progressbar +from progressbar import widgets + + +def test_numeric_samples() -> None: + samples = 5 + samples_widget = widgets.SamplesMixin(samples=samples) + bar = progressbar.ProgressBar(widgets=[samples_widget]) + + # Force updates in all cases + samples_widget.INTERVAL = timedelta(0) + + start = datetime(2000, 1, 1) + + bar.value = 1 + bar.last_update_time = start + timedelta(seconds=bar.value) + assert samples_widget(bar, None, True) == (None, None) + + for i in range(2, 6): + bar.value = i + bar.last_update_time = start + timedelta(seconds=i) + assert samples_widget(bar, None, True) == (timedelta(0, i - 1), i - 1) + + bar.value = 8 + bar.last_update_time = start + timedelta(seconds=bar.value) + assert samples_widget(bar, None, True) == (timedelta(0, 6), 6) + + bar.value = 10 + bar.last_update_time = start + timedelta(seconds=bar.value) + assert samples_widget(bar, None, True) == (timedelta(0, 7), 7) + + bar.value = 20 + bar.last_update_time = start + timedelta(seconds=bar.value) + assert samples_widget(bar, None, True) == (timedelta(0, 16), 16) + + assert samples_widget(bar, None)[1] == SliceableDeque( + [4, 5, 8, 10, 20], + ) + + +def test_timedelta_samples() -> None: + samples = timedelta(seconds=5) + samples_widget = widgets.SamplesMixin(samples=samples) + bar = progressbar.ProgressBar(widgets=[samples_widget]) + + # Force updates in all cases + samples_widget.INTERVAL = timedelta(0) + + start = datetime(2000, 1, 1) + + bar.value = 1 + bar.last_update_time = start + timedelta(seconds=bar.value) + assert samples_widget(bar, None, True) == (None, None) + + for i in range(2, 6): + time.sleep(1) + bar.value = i + bar.last_update_time = start + timedelta(seconds=i) + assert samples_widget(bar, None, True) == (timedelta(0, i - 1), i - 1) + + bar.value = 8 + bar.last_update_time = start + timedelta(seconds=bar.value) + assert samples_widget(bar, None, True) == (timedelta(0, 6), 6) + + bar.last_update_time = start + timedelta(seconds=bar.value) + bar.value = 8 + assert samples_widget(bar, None, True) == (timedelta(0, 6), 6) + + bar.value = 10 + bar.last_update_time = start + timedelta(seconds=bar.value) + assert samples_widget(bar, None, True) == (timedelta(0, 6), 6) + + bar.value = 20 + bar.last_update_time = start + timedelta(seconds=bar.value) + assert samples_widget(bar, None, True) == (timedelta(0, 10), 10) + + assert samples_widget(bar, None)[1] == [10, 20] + + +def test_timedelta_no_update() -> None: + samples = timedelta(seconds=0.1) + samples_widget = widgets.SamplesMixin(samples=samples) + bar = progressbar.ProgressBar(widgets=[samples_widget]) + bar.update() + + assert samples_widget(bar, None, True) == (None, None) + assert samples_widget(bar, None, False)[1] == [0] + assert samples_widget(bar, None, True) == (None, None) + assert samples_widget(bar, None, False)[1] == [0] + + time.sleep(1) + assert samples_widget(bar, None, True) == (None, None) + assert samples_widget(bar, None, False)[1] == [0] + + bar.update(1) + assert samples_widget(bar, None, True) == (timedelta(0, 1), 1) + assert samples_widget(bar, None, False)[1] == [0, 1] + + time.sleep(1) + bar.update(2) + assert samples_widget(bar, None, True) == (timedelta(0, 1), 1) + assert samples_widget(bar, None, False)[1] == [1, 2] + + time.sleep(0.01) + bar.update(3) + assert samples_widget(bar, None, True) == (timedelta(0, 1), 1) + assert samples_widget(bar, None, False)[1] == [1, 2] diff --git a/tests/test_speed.py b/tests/test_speed.py new file mode 100644 index 00000000..4f53639e --- /dev/null +++ b/tests/test_speed.py @@ -0,0 +1,36 @@ +import pytest + +import progressbar + + +@pytest.mark.parametrize( + 'total_seconds_elapsed,value,expected', + [ + (1, 0, ' 0.0 s/B'), + (1, 0.01, '100.0 s/B'), + (1, 0.1, ' 0.1 B/s'), + (1, 1, ' 1.0 B/s'), + (1, 2**10 - 1, '1023.0 B/s'), + (1, 2**10 + 0, ' 1.0 KiB/s'), + (1, 2**20, ' 1.0 MiB/s'), + (1, 2**30, ' 1.0 GiB/s'), + (1, 2**40, ' 1.0 TiB/s'), + (1, 2**50, ' 1.0 PiB/s'), + (1, 2**60, ' 1.0 EiB/s'), + (1, 2**70, ' 1.0 ZiB/s'), + (1, 2**80, ' 1.0 YiB/s'), + (1, 2**90, '1024.0 YiB/s'), + ], +) +def test_file_transfer_speed(total_seconds_elapsed, value, expected) -> None: + widget = progressbar.FileTransferSpeed() + assert ( + widget( + None, + dict( + total_seconds_elapsed=total_seconds_elapsed, + value=value, + ), + ) + == expected + ) diff --git a/tests/test_stream.py b/tests/test_stream.py new file mode 100644 index 00000000..d14845d8 --- /dev/null +++ b/tests/test_stream.py @@ -0,0 +1,150 @@ +import io +import os +import sys + +import pytest + +import progressbar +from progressbar import terminal + + +def test_nowrap() -> None: + # Make sure we definitely unwrap + for _i in range(5): + progressbar.streams.unwrap(stderr=True, stdout=True) + + stdout = sys.stdout + stderr = sys.stderr + + progressbar.streams.wrap() + + assert stdout == sys.stdout + assert stderr == sys.stderr + + progressbar.streams.unwrap() + + assert stdout == sys.stdout + assert stderr == sys.stderr + + # Make sure we definitely unwrap + for _i in range(5): + progressbar.streams.unwrap(stderr=True, stdout=True) + + +def test_wrap() -> None: + # Make sure we definitely unwrap + for _i in range(5): + progressbar.streams.unwrap(stderr=True, stdout=True) + + stdout = sys.stdout + stderr = sys.stderr + + progressbar.streams.wrap(stderr=True, stdout=True) + + assert stdout != sys.stdout + assert stderr != sys.stderr + + # Wrap again + stdout = sys.stdout + stderr = sys.stderr + + progressbar.streams.wrap(stderr=True, stdout=True) + + assert stdout == sys.stdout + assert stderr == sys.stderr + + # Make sure we definitely unwrap + for _i in range(5): + progressbar.streams.unwrap(stderr=True, stdout=True) + + +def test_excepthook() -> None: + progressbar.streams.wrap(stderr=True, stdout=True) + + try: + raise RuntimeError() # noqa: TRY301 + except RuntimeError: + progressbar.streams.excepthook(*sys.exc_info()) + + progressbar.streams.unwrap_excepthook() + progressbar.streams.unwrap_excepthook() + + +def test_fd_as_io_stream() -> None: + stream = io.StringIO() + with progressbar.ProgressBar(fd=stream) as pb: + for i in range(101): + pb.update(i) + stream.close() + + +def test_no_newlines() -> None: + kwargs = dict( + redirect_stderr=True, + redirect_stdout=True, + line_breaks=False, + is_terminal=True, + ) + + with progressbar.ProgressBar(**kwargs) as bar: + for i in range(5): + bar.update(i) + + for i in range(5, 10): + try: + print('\n\n', file=progressbar.streams.stdout) + print('\n\n', file=progressbar.streams.stderr) + except ValueError: + pass + bar.update(i) + + +@pytest.mark.parametrize('stream', [sys.__stdout__, sys.__stderr__]) +@pytest.mark.skipif(os.name == 'nt', reason='Windows does not support this') +def test_fd_as_standard_streams(stream) -> None: + with progressbar.ProgressBar(fd=stream) as pb: + for i in range(101): + pb.update(i) + + +def test_line_offset_stream_wrapper() -> None: + stream = terminal.LineOffsetStreamWrapper(5, io.StringIO()) + stream.write('Hello World!') + + +def test_last_line_stream_methods() -> None: + stream = terminal.LastLineStream(io.StringIO()) + + # Test write method + stream.write('Hello World!') + assert stream.read() == 'Hello World!' + assert stream.read(5) == 'Hello' + + # Test flush method + stream.flush() + assert stream.line == 'Hello World!' + assert stream.readline() == 'Hello World!' + assert stream.readline(5) == 'Hello' + + # Test truncate method + stream.truncate(5) + assert stream.line == 'Hello' + stream.truncate() + assert stream.line == '' + + # Test seekable/readable + assert not stream.seekable() + assert stream.readable() + + stream.writelines(['a', 'b', 'c']) + assert stream.read() == 'c' + + assert list(stream) == ['c'] + + with stream: + stream.write('Hello World!') + assert stream.read() == 'Hello World!' + assert stream.read(5) == 'Hello' + + # Test close method + stream.close() diff --git a/tests/test_terminal.py b/tests/test_terminal.py new file mode 100644 index 00000000..3980e5f8 --- /dev/null +++ b/tests/test_terminal.py @@ -0,0 +1,188 @@ +import signal +import sys +import time +from datetime import timedelta + +import progressbar +from progressbar import terminal + + +def test_left_justify() -> None: + """Left justify using the terminal width""" + p = progressbar.ProgressBar( + widgets=[progressbar.BouncingBar(marker=progressbar.RotatingMarker())], + max_value=100, + term_width=20, + left_justify=True, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + + +def test_right_justify() -> None: + """Right justify using the terminal width""" + p = progressbar.ProgressBar( + widgets=[progressbar.BouncingBar(marker=progressbar.RotatingMarker())], + max_value=100, + term_width=20, + left_justify=False, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + + +def test_auto_width(monkeypatch) -> None: + """Right justify using the terminal width""" + + def ioctl(*args): + return '\xbf\x00\xeb\x00\x00\x00\x00\x00' + + def fake_signal(signal, func): + pass + + try: + import fcntl + + monkeypatch.setattr(fcntl, 'ioctl', ioctl) + monkeypatch.setattr(signal, 'signal', fake_signal) + p = progressbar.ProgressBar( + widgets=[ + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ], + max_value=100, + left_justify=True, + term_width=None, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + except ImportError: + pass # Skip on Windows + + +def test_fill_right() -> None: + """Right justify using the terminal width""" + p = progressbar.ProgressBar( + widgets=[progressbar.BouncingBar(fill_left=False)], + max_value=100, + term_width=20, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + + +def test_fill_left() -> None: + """Right justify using the terminal width""" + p = progressbar.ProgressBar( + widgets=[progressbar.BouncingBar(fill_left=True)], + max_value=100, + term_width=20, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + + +def test_no_fill(monkeypatch) -> None: + """Simply bounce within the terminal width""" + bar = progressbar.BouncingBar() + bar.INTERVAL = timedelta(seconds=1) + p = progressbar.ProgressBar( + widgets=[bar], + max_value=progressbar.UnknownLength, + term_width=20, + ) + + assert p.term_width is not None + for i in range(30): + p.update(i, force=True) + # Fake the start time so we can actually emulate a moving progress bar + p.start_time = p.start_time - timedelta(seconds=i) + + +def test_stdout_redirection() -> None: + p = progressbar.ProgressBar( + fd=sys.stdout, + max_value=10, + redirect_stdout=True, + ) + + for i in range(10): + print('', file=sys.stdout) + p.update(i) + + +def test_double_stdout_redirection() -> None: + p = progressbar.ProgressBar(max_value=10, redirect_stdout=True) + p2 = progressbar.ProgressBar(max_value=10, redirect_stdout=True) + + for i in range(10): + print('', file=sys.stdout) + p.update(i) + p2.update(i) + + +def test_stderr_redirection() -> None: + p = progressbar.ProgressBar(max_value=10, redirect_stderr=True) + + for i in range(10): + print('', file=sys.stderr) + p.update(i) + + +def test_stdout_stderr_redirection() -> None: + p = progressbar.ProgressBar( + max_value=10, + redirect_stdout=True, + redirect_stderr=True, + ) + p.start() + + for i in range(10): + time.sleep(0.01) + print('', file=sys.stdout) + print('', file=sys.stderr) + p.update(i) + + p.finish() + + +def test_resize(monkeypatch) -> None: + def ioctl(*args): + return '\xbf\x00\xeb\x00\x00\x00\x00\x00' + + def fake_signal(signal, func): + pass + + try: + import fcntl + + monkeypatch.setattr(fcntl, 'ioctl', ioctl) + monkeypatch.setattr(signal, 'signal', fake_signal) + + p = progressbar.ProgressBar(max_value=10) + p.start() + + for i in range(10): + p.update(i) + p._handle_resize() + + p.finish() + except ImportError: + pass # Skip on Windows + + +def test_base() -> None: + assert str(terminal.CUP) + assert str(terminal.CLEAR_SCREEN_ALL_AND_HISTORY) + + terminal.clear_line(0) + terminal.clear_line(1) diff --git a/tests/test_timed.py b/tests/test_timed.py new file mode 100644 index 00000000..ee19ab95 --- /dev/null +++ b/tests/test_timed.py @@ -0,0 +1,187 @@ +import datetime +import time + +import progressbar + + +def test_timer() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.Timer(), + ] + p = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + p.update() + p.update(1) + p._needs_update() + time.sleep(0.001) + p.update(1) + p.finish() + + +def test_eta() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.ETA(), + ] + p = progressbar.ProgressBar( + min_value=0, + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + time.sleep(0.001) + p.update(0) + time.sleep(0.001) + p.update(1) + time.sleep(0.001) + p.update(1) + time.sleep(0.001) + p.update(2) + time.sleep(0.001) + p.finish() + time.sleep(0.001) + p.update(2) + + +def test_adaptive_eta() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.AdaptiveETA(), + ] + widgets[0].INTERVAL = datetime.timedelta(microseconds=1) + p = progressbar.ProgressBar( + max_value=2, + samples=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + for _i in range(20): + p.update(1) + time.sleep(0.001) + p.finish() + + +def test_adaptive_transfer_speed() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.AdaptiveTransferSpeed(), + ] + p = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + p.update(1) + time.sleep(0.001) + p.update(1) + p.finish() + + +def test_etas(monkeypatch) -> None: + """Compare file transfer speed to adaptive transfer speed""" + n = 10 + interval = datetime.timedelta(seconds=1) + widgets = [ + progressbar.FileTransferSpeed(), + progressbar.AdaptiveTransferSpeed(samples=n / 2), + ] + + datas = [] + + # Capture the output sent towards the `_speed` method + def calculate_eta(self, value, elapsed): + """Capture the widget output""" + data = dict( + value=value, + elapsed=int(elapsed), + ) + datas.append(data) + return 0, 0 + + monkeypatch.setattr(progressbar.FileTransferSpeed, '_speed', calculate_eta) + monkeypatch.setattr( + progressbar.AdaptiveTransferSpeed, + '_speed', + calculate_eta, + ) + + for widget in widgets: + widget.INTERVAL = interval + + p = progressbar.ProgressBar( + max_value=n, + widgets=widgets, + poll_interval=interval, + ) + + # Run the first few samples at a low speed and speed up later so we can + # compare the results from both widgets + for i in range(n): + p.update(i) + if i > n / 2: + time.sleep(1) + else: + time.sleep(10) + p.finish() + + # Due to weird travis issues, the actual testing is disabled for now + # import pprint + # pprint.pprint(datas[::2]) + # pprint.pprint(datas[1::2]) + + # for i, (a, b) in enumerate(zip(datas[::2], datas[1::2])): + # # Because the speed is identical initially, the results should be the + # # same for adaptive and regular transfer speed. Only when the speed + # # changes we should start see a lot of differences between the two + # if i < (n / 2 - 1): + # assert a['elapsed'] == b['elapsed'] + # if i > (n / 2 + 1): + # assert a['elapsed'] > b['elapsed'] + + +def test_non_changing_eta() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.AdaptiveETA(), + progressbar.ETA(), + progressbar.AdaptiveTransferSpeed(), + ] + p = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + p.update(1) + time.sleep(0.001) + p.update(1) + p.finish() + + +def test_eta_not_available(): + """ + When ETA is not available (data coming from a generator), + ETAs should not raise exceptions. + """ + + def gen(): + yield from range(200) + + widgets = [progressbar.AdaptiveETA(), progressbar.ETA()] + + bar = progressbar.ProgressBar(widgets=widgets) + for _i in bar(gen()): + pass diff --git a/tests/test_timer.py b/tests/test_timer.py new file mode 100644 index 00000000..083e1b18 --- /dev/null +++ b/tests/test_timer.py @@ -0,0 +1,60 @@ +from datetime import timedelta + +import pytest + +import progressbar + + +@pytest.mark.parametrize( + 'poll_interval,expected', + [ + (1, 1), + (timedelta(seconds=1), 1), + (0.001, 0.001), + (timedelta(microseconds=1000), 0.001), + ], +) +@pytest.mark.parametrize( + 'parameter', + [ + 'poll_interval', + 'min_poll_interval', + ], +) +def test_poll_interval(parameter, poll_interval, expected) -> None: + # Test int, float and timedelta intervals + bar = progressbar.ProgressBar(**{parameter: poll_interval}) + assert getattr(bar, parameter) == expected + + +@pytest.mark.parametrize( + 'interval', + [ + 1, + timedelta(seconds=1), + ], +) +def test_intervals(monkeypatch, interval) -> None: + monkeypatch.setattr( + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + interval, + ) + bar = progressbar.ProgressBar(max_value=100) + + # Initially there should be no last_update_time + assert bar.last_update_time is None + + # After updating there should be a last_update_time + bar.update(1) + assert bar.last_update_time + + # We should not need an update if the time is nearly the same as before + last_update_time = bar.last_update_time + bar.update(2) + assert bar.last_update_time == last_update_time + + # We should need an update if we're beyond the poll_interval + bar._last_update_time -= 2 + bar.update(3) + assert bar.last_update_time != last_update_time diff --git a/tests/test_unicode.py b/tests/test_unicode.py new file mode 100644 index 00000000..3babbddd --- /dev/null +++ b/tests/test_unicode.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import time + +import pytest +from python_utils import converters + +import progressbar + + +@pytest.mark.parametrize( + 'name,markers', + [ + ('line arrows', '←↖↑↗→↘↓↙'), + ('block arrows', '◢◣◤◥'), + ('wheels', '◐◓◑◒'), + ], +) +@pytest.mark.parametrize('as_unicode', [True, False]) +def test_markers(name, markers: bytes | str, as_unicode) -> None: + if as_unicode: + markers = converters.to_unicode(markers) + else: + markers = converters.to_str(markers) + + widgets = [ + f'{name.capitalize()}: ', + progressbar.AnimatedMarker(markers=markers), + ] + bar = progressbar.ProgressBar(widgets=widgets) + bar._MINIMUM_UPDATE_INTERVAL = 1e-12 + for _i in bar(iter(range(24))): + time.sleep(0.001) diff --git a/tests/test_unknown_length.py b/tests/test_unknown_length.py new file mode 100644 index 00000000..65a54779 --- /dev/null +++ b/tests/test_unknown_length.py @@ -0,0 +1,30 @@ +import progressbar + + +def test_unknown_length() -> None: + pb = progressbar.ProgressBar( + widgets=[progressbar.AnimatedMarker()], + max_value=progressbar.UnknownLength, + ) + assert pb.max_value is progressbar.UnknownLength + + +def test_unknown_length_default_widgets() -> None: + # The default widgets picked should work without a known max_value + pb = progressbar.ProgressBar(max_value=progressbar.UnknownLength).start() + for i in range(60): + pb.update(i) + pb.finish() + + +def test_unknown_length_at_start() -> None: + # The default widgets should be picked after we call .start() + pb = progressbar.ProgressBar().start(max_value=progressbar.UnknownLength) + for i in range(60): + pb.update(i) + pb.finish() + + pb2 = progressbar.ProgressBar().start(max_value=progressbar.UnknownLength) + for w in pb2.widgets: + print(type(w), repr(w)) + assert any(isinstance(w, progressbar.Bar) for w in pb2.widgets) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..e347acdb --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,114 @@ +import io + +import pytest + +import progressbar +import progressbar.env + + +@pytest.mark.parametrize( + 'value,expected', + [ + (None, None), + ('', None), + ('1', True), + ('y', True), + ('t', True), + ('yes', True), + ('true', True), + ('True', True), + ('0', False), + ('n', False), + ('f', False), + ('no', False), + ('false', False), + ('False', False), + ], +) +def test_env_flag(value, expected, monkeypatch) -> None: + if value is not None: + monkeypatch.setenv('TEST_ENV', value) + assert progressbar.env.env_flag('TEST_ENV') == expected + + if value: + monkeypatch.setenv('TEST_ENV', value.upper()) + assert progressbar.env.env_flag('TEST_ENV') == expected + + monkeypatch.undo() + + +def test_is_terminal(monkeypatch) -> None: + fd = io.StringIO() + + monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL', raising=False) + monkeypatch.setattr(progressbar.env, 'JUPYTER', False) + + assert progressbar.env.is_terminal(fd) is False + assert progressbar.env.is_terminal(fd, True) is True + assert progressbar.env.is_terminal(fd, False) is False + + monkeypatch.setattr(progressbar.env, 'JUPYTER', True) + assert progressbar.env.is_terminal(fd) is True + + # Sanity check + monkeypatch.setattr(progressbar.env, 'JUPYTER', False) + assert progressbar.env.is_terminal(fd) is False + + monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'true') + assert progressbar.env.is_terminal(fd) is True + monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'false') + assert progressbar.env.is_terminal(fd) is False + monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL') + + # Sanity check + assert progressbar.env.is_terminal(fd) is False + + +def test_is_ansi_terminal(monkeypatch) -> None: + fd = io.StringIO() + + monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL', raising=False) + monkeypatch.setattr(progressbar.env, 'JUPYTER', False) + + assert not progressbar.env.is_ansi_terminal(fd) + assert progressbar.env.is_ansi_terminal(fd, True) is True + assert progressbar.env.is_ansi_terminal(fd, False) is False + + monkeypatch.setattr(progressbar.env, 'JUPYTER', True) + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.setattr(progressbar.env, 'JUPYTER', False) + + # Sanity check + assert not progressbar.env.is_ansi_terminal(fd) + + monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'true') + assert not progressbar.env.is_ansi_terminal(fd) + monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'false') + assert not progressbar.env.is_ansi_terminal(fd) + monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL') + + # Sanity check + assert not progressbar.env.is_ansi_terminal(fd) + + # Fake TTY mode for environment testing + fd.isatty = lambda: True + monkeypatch.setenv('TERM', 'xterm') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.setenv('TERM', 'xterm-256') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.setenv('TERM', 'xterm-256color') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.setenv('TERM', 'xterm-24bit') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.delenv('TERM') + + monkeypatch.setenv('ANSICON', 'true') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.delenv('ANSICON') + assert not progressbar.env.is_ansi_terminal(fd) + + def raise_error(): + raise RuntimeError('test') + + fd.isatty = raise_error + assert not progressbar.env.is_ansi_terminal(fd) diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 00000000..7ab3d88e --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import time + +import pytest + +import progressbar + +max_values: list[None | type[progressbar.base.UnknownLength] | int] = [ + None, + 10, + progressbar.UnknownLength, +] + + +def test_create_wrapper() -> None: + with pytest.raises(AssertionError): + progressbar.widgets.create_wrapper('ab') + + with pytest.raises(RuntimeError): + progressbar.widgets.create_wrapper(123) + + +def test_widgets_small_values() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.AbsoluteETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + p = progressbar.ProgressBar(widgets=widgets, max_value=10).start() + p.update(0) + for i in range(10): + time.sleep(1) + p.update(i + 1) + p.finish() + + +@pytest.mark.parametrize('max_value', [10**6, 10**8]) +def test_widgets_large_values(max_value) -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.AbsoluteETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + p = progressbar.ProgressBar(widgets=widgets, max_value=max_value).start() + for i in range(0, 10**6, 10**4): + time.sleep(1) + p.update(i + 1) + p.finish() + + +def test_format_widget() -> None: + widgets = [ + progressbar.FormatLabel(f'%({mapping})r') + for mapping in progressbar.FormatLabel.mapping + ] + p = progressbar.ProgressBar(widgets=widgets) + for _ in p(range(10)): + time.sleep(1) + + +@pytest.mark.parametrize('max_value', [None, 10]) +def test_all_widgets_small_values(max_value) -> None: + widgets = [ + progressbar.Timer(), + progressbar.ETA(), + progressbar.AdaptiveETA(), + progressbar.AbsoluteETA(), + progressbar.DataSize(), + progressbar.FileTransferSpeed(), + progressbar.AdaptiveTransferSpeed(), + progressbar.AnimatedMarker(), + progressbar.Counter(), + progressbar.Percentage(), + progressbar.FormatLabel('%(value)d'), + progressbar.SimpleProgress(), + progressbar.Bar(), + progressbar.ReverseBar(), + progressbar.BouncingBar(), + progressbar.CurrentTime(), + progressbar.CurrentTime(microseconds=False), + progressbar.CurrentTime(microseconds=True), + ] + p = progressbar.ProgressBar(widgets=widgets, max_value=max_value) + for i in range(10): + time.sleep(1) + p.update(i + 1) + p.finish() + + +@pytest.mark.parametrize('max_value', [10**6, 10**7]) +def test_all_widgets_large_values(max_value) -> None: + widgets = [ + progressbar.Timer(), + progressbar.ETA(), + progressbar.AdaptiveETA(), + progressbar.AbsoluteETA(), + progressbar.DataSize(), + progressbar.FileTransferSpeed(), + progressbar.AdaptiveTransferSpeed(), + progressbar.AnimatedMarker(), + progressbar.Counter(), + progressbar.Percentage(), + progressbar.FormatLabel('%(value)d/%(max_value)d'), + progressbar.SimpleProgress(), + progressbar.Bar(fill=lambda progress, data, width: '#'), + progressbar.ReverseBar(), + progressbar.BouncingBar(), + progressbar.FormatCustomText('Custom %(text)s', dict(text='text')), + ] + p = progressbar.ProgressBar(widgets=widgets, max_value=max_value) + p.update() + time.sleep(1) + p.update() + + for i in range(0, 10**6, 10**4): + time.sleep(1) + p.update(i) + + +@pytest.mark.parametrize('min_width', [None, 1, 2, 80, 120]) +@pytest.mark.parametrize('term_width', [1, 2, 80, 120]) +def test_all_widgets_min_width(min_width, term_width) -> None: + widgets = [ + progressbar.Timer(min_width=min_width), + progressbar.ETA(min_width=min_width), + progressbar.AdaptiveETA(min_width=min_width), + progressbar.AbsoluteETA(min_width=min_width), + progressbar.DataSize(min_width=min_width), + progressbar.FileTransferSpeed(min_width=min_width), + progressbar.AdaptiveTransferSpeed(min_width=min_width), + progressbar.AnimatedMarker(min_width=min_width), + progressbar.Counter(min_width=min_width), + progressbar.Percentage(min_width=min_width), + progressbar.FormatLabel('%(value)d', min_width=min_width), + progressbar.SimpleProgress(min_width=min_width), + progressbar.Bar(min_width=min_width), + progressbar.ReverseBar(min_width=min_width), + progressbar.BouncingBar(min_width=min_width), + progressbar.FormatCustomText( + 'Custom %(text)s', + dict(text='text'), + min_width=min_width, + ), + progressbar.DynamicMessage('custom', min_width=min_width), + progressbar.CurrentTime(min_width=min_width), + ] + p = progressbar.ProgressBar(widgets=widgets, term_width=term_width) + p.update(0) + p.update() + for widget in p._format_widgets(): + if min_width and min_width > term_width: + assert widget == '' + else: + assert widget != '' + + +@pytest.mark.parametrize('max_width', [None, 1, 2, 80, 120]) +@pytest.mark.parametrize('term_width', [1, 2, 80, 120]) +def test_all_widgets_max_width(max_width, term_width) -> None: + widgets = [ + progressbar.Timer(max_width=max_width), + progressbar.ETA(max_width=max_width), + progressbar.AdaptiveETA(max_width=max_width), + progressbar.AbsoluteETA(max_width=max_width), + progressbar.DataSize(max_width=max_width), + progressbar.FileTransferSpeed(max_width=max_width), + progressbar.AdaptiveTransferSpeed(max_width=max_width), + progressbar.AnimatedMarker(max_width=max_width), + progressbar.Counter(max_width=max_width), + progressbar.Percentage(max_width=max_width), + progressbar.FormatLabel('%(value)d', max_width=max_width), + progressbar.SimpleProgress(max_width=max_width), + progressbar.Bar(max_width=max_width), + progressbar.ReverseBar(max_width=max_width), + progressbar.BouncingBar(max_width=max_width), + progressbar.FormatCustomText( + 'Custom %(text)s', + dict(text='text'), + max_width=max_width, + ), + progressbar.DynamicMessage('custom', max_width=max_width), + progressbar.CurrentTime(max_width=max_width), + ] + p = progressbar.ProgressBar(widgets=widgets, term_width=term_width) + p.update(0) + p.update() + for widget in p._format_widgets(): + if max_width and max_width < term_width: + assert widget == '' + else: + assert widget != '' diff --git a/tests/test_windows.py b/tests/test_windows.py new file mode 100644 index 00000000..4c95fae4 --- /dev/null +++ b/tests/test_windows.py @@ -0,0 +1,90 @@ +import os +import sys +import time + +import pytest + +if os.name == 'nt': + import win32console # "pip install pypiwin32" to get this +else: + pytest.skip('skipping windows-only tests', allow_module_level=True) + +import progressbar + +pytest_plugins = 'pytester' +_WIDGETS = [ + progressbar.Percentage(), + ' ', + progressbar.Bar(), + ' ', + progressbar.FileTransferSpeed(), + ' ', + progressbar.ETA(), +] +_MB: int = 1024 * 1024 + + +# --------------------------------------------------------------------------- +def scrape_console(line_count): + pcsb = win32console.GetStdHandle(win32console.STD_OUTPUT_HANDLE) + csbi = pcsb.GetConsoleScreenBufferInfo() + col_max = csbi['Size'].X + row_max = csbi['CursorPosition'].Y + + line_count = min(line_count, row_max) + lines = [] + for row in range(line_count): + pct = win32console.PyCOORDType(0, row + row_max - line_count) + line = pcsb.ReadConsoleOutputCharacter(col_max, pct) + lines.append(line.rstrip()) + return lines + + +# --------------------------------------------------------------------------- +def runprogress() -> int: + print('***BEGIN***') + b = progressbar.ProgressBar( + widgets=['example.m4v: ', *_WIDGETS], + max_value=10 * _MB, + ) + for i in range(10): + b.update((i + 1) * _MB) + time.sleep(0.25) + b.finish() + print('***END***') + return 0 + + +# --------------------------------------------------------------------------- +def find(lines, x): + try: + return lines.index(x) + except ValueError: + return -sys.maxsize + + +# --------------------------------------------------------------------------- +def test_windows(testdir: pytest.Testdir) -> None: + testdir.run( + sys.executable, '-c', 'import progressbar; print(progressbar.__file__)' + ) + + +def main() -> int: + runprogress() + + scraped_lines = scrape_console(100) + # reverse lines so we find the LAST instances of output. + scraped_lines.reverse() + index_begin = find(scraped_lines, '***BEGIN***') + index_end = find(scraped_lines, '***END***') + + if index_end + 2 != index_begin: + print('ERROR: Unexpected multi-line output from progressbar') + print(f'{index_begin=} {index_end=}') + return 1 + return 0 + + +if __name__ == '__main__': + main() diff --git a/tests/test_with.py b/tests/test_with.py new file mode 100644 index 00000000..3d2253f5 --- /dev/null +++ b/tests/test_with.py @@ -0,0 +1,19 @@ +import progressbar + + +def test_with() -> None: + with progressbar.ProgressBar(max_value=10) as p: + for i in range(10): + p.update(i) + + +def test_with_stdout_redirection() -> None: + with progressbar.ProgressBar(max_value=10, redirect_stdout=True) as p: + for i in range(10): + p.update(i) + + +def test_with_extra_start() -> None: + with progressbar.ProgressBar(max_value=10) as p: + p.start() + p.start() diff --git a/tests/test_wrappingio.py b/tests/test_wrappingio.py new file mode 100644 index 00000000..71a711b4 --- /dev/null +++ b/tests/test_wrappingio.py @@ -0,0 +1,62 @@ +import io +import sys + +import pytest + +from progressbar import utils + + +def test_wrappingio() -> None: + # Test the wrapping of our version of sys.stdout` ` q + fd = utils.WrappingIO(sys.stdout) + assert fd.fileno() + assert not fd.isatty() + + assert not fd.read() + assert not fd.readline() + assert not fd.readlines() + assert fd.readable() + + assert not fd.seek(0) + assert fd.seekable() + assert not fd.tell() + + assert not fd.truncate() + assert fd.writable() + assert fd.write('test') + assert not fd.writelines(['test']) + + with pytest.raises(StopIteration): + next(fd) + with pytest.raises(StopIteration): + next(iter(fd)) + + +def test_wrapping_stringio() -> None: + # Test the wrapping of our version of sys.stdout` ` q + string_io = io.StringIO() + fd = utils.WrappingIO(string_io) + with fd: + with pytest.raises(io.UnsupportedOperation): + fd.fileno() + + assert not fd.isatty() + + assert not fd.read() + assert not fd.readline() + assert not fd.readlines() + assert fd.readable() + + assert not fd.seek(0) + assert fd.seekable() + assert not fd.tell() + + assert not fd.truncate() + assert fd.writable() + assert fd.write('test') + assert not fd.writelines(['test']) + + with pytest.raises(StopIteration): + next(fd) + with pytest.raises(StopIteration): + next(iter(fd)) diff --git a/tox.ini b/tox.ini index 7af31ef0..c6812323 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,65 @@ [tox] -envlist = py26, py27, py33, pypy +envlist = + py38 + py39 + py310 + py311 + py312 + docs + black + ruff +; mypy +; codespell +skip_missing_interpreters = True [testenv] deps = - nose - coverage - tissue + -r{toxinidir}/tests/requirements.txt + pyright +commands = + pyright + py.test --basetemp="{envtmpdir}" --confcutdir=.. {posargs} +skip_install = true + +[testenv:mypy] +changedir = +basepython = python3 +deps = mypy +commands = mypy {toxinidir}/progressbar + +[testenv:black] +basepython = python3 +deps = black +commands = black --skip-string-normalization --line-length 79 {toxinidir}/progressbar + +[testenv:docs] +changedir = +basepython = python3 +deps = -r{toxinidir}/docs/requirements.txt +allowlist_externals = + rm + mkdir +whitelist_externals = + rm + cd + mkdir +commands = + rm -f docs/modules.rst + mkdir -p docs/_static + sphinx-apidoc -e -o docs/ progressbar */os_specific/* + rm -f docs/modules.rst + sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html {posargs} +[testenv:ruff] commands = - nosetests + ruff check + ruff format +deps = ruff +skip_install = true +[testenv:codespell] +changedir = {toxinidir} +commands = codespell . +deps = codespell +skip_install = true +command = codespell