diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 0d8211a4..00000000 --- a/.coveragerc +++ /dev/null @@ -1,16 +0,0 @@ -[run] -branch = True -source = - progressbar - tests -omit = - */mock/* - */nose/* -[paths] -source = - progressbar -[report] -fail_under = 100 -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..59502c74 --- /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.9', '3.10', '3.11', '3.12'] + + 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 fd960a5f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -sudo: false -language: python -python: - - '2.7' - - '3.3' - - '3.4' - - '3.5' - - pypy -install: - - pip install . - - pip install -r tests/requirements.txt -before_script: flake8 --ignore=W391 progressbar tests -script: - - python setup.py test - - python examples.py -after_success: - - coveralls diff --git a/CHANGES.rst b/CHANGES.rst index 10afd353..ebb9d853 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,150 +9,3 @@ releases or the commit log: - https://github.com/WoLpH/python-progressbar/commits/develop Hint: click on the `...` button to see the change message. - -The list below should be the same as the list of releases above but tends to be -out of date and/or incomplete: - -.. changelog:: - :version: dev - :released: Ongoing - - .. change:: - :tags: docs - - Updated CHANGES. - -.. changelog:: - :version: 3.15 - :released: 2017-03-15 - - .. change:: - Large performance improvements - - .. change:: - Dropped Pypy3 support in Travis tests - - .. change:: - Improved output redirection (fixed several bugs) - - .. change:: - Showing N/A when no numbers are available - - .. change:: - Improved docs - - -.. changelog:: - :version: 3.5 - :released: 2015-11-15 - - .. change:: - Added support for size-dependent widgets - - .. change:: - Fixed inheritance issues - -.. changelog:: - :version: 3.4 - :released: 2015-11-11 - - .. change:: - Added usage documentation - - .. change:: - Fixed several bugs including default widths and output redirection - - :version: 3.3 - :released: 2015-10-12 - - .. change:: - :tags: unknown-length - - Fixed `UnknownLength` handling. Thanks to @takluyver - -.. changelog:: - :version: 3.2 - :released: 2015-10-11 - - .. change:: - :tags: packaging - - Cookiecutter package - -.. changelog:: - :version: 3.1 - :released: 2015-07-11 - - .. change:: - :tags: python 3 - - Python 3 support - -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. - -.. todo:: vim: set filetype=rst: diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 63f9db49..6e24af25 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -3,7 +3,7 @@ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every -little bit helps, and credit will always be given. +little bit helps, and credit will always be given. You can contribute in many ways: @@ -36,7 +36,7 @@ is open to whoever wants to implement it. Write Documentation ~~~~~~~~~~~~~~~~~~~ -Python Progressbar could always use more documentation, whether as part of the +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. @@ -62,11 +62,10 @@ Ready to contribute? Here's how to set up `python-progressbar` for local develop $ 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:: +3. Install your local copy into a virtualenv. Assuming you have `uv` installed, this is how you set up your fork for local development:: - $ mkvirtualenv progressbar $ cd progressbar/ - $ pip install -e . + $ uv sync 4. Create a branch for local development with `git-flow-avh`_:: @@ -75,7 +74,7 @@ Ready to contribute? Here's how to set up `python-progressbar` for local develop 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:: @@ -85,7 +84,7 @@ Ready to contribute? Here's how to set up `python-progressbar` for local develop $ 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`_:: @@ -111,7 +110,7 @@ Before you submit a pull request, check that it meets these guidelines: 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 +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. @@ -123,4 +122,3 @@ To run a subset of tests:: $ py.test tests/some_test.py .. _git-flow-avh: https://github.com/petervanderdoes/gitflow - diff --git a/LICENSE b/LICENSE index f3aaf432..38887b7c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015, Rick van Hattem (Wolph) +Copyright (c) 2022, Rick van Hattem (Wolph) All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/MANIFEST.in b/MANIFEST.in index 0b5253f6..f387924e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,12 @@ +recursive-exclude *.pyc +recursive-exclude *.pyo +recursive-exclude *.html include AUTHORS.rst include CHANGES.rst include CONTRIBUTING.rst include LICENSE include README.rst -include README.txt include examples.py include requirements.txt +include Makefile +include pytest.ini diff --git a/Makefile b/Makefile deleted file mode 100644 index 72116117..00000000 --- a/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: clean-pyc clean-build docs - -help: - @echo "clean-build - remove build artifacts" - @echo "clean-pyc - remove Python file artifacts" - @echo "lint - check style with flake8" - @echo "test - run tests quickly with the default Python" - @echo "testall - run tests on every Python version with tox" - @echo "coverage - check code coverage quickly with the default Python" - @echo "docs - generate Sphinx HTML documentation, including API docs" - @echo "release - package and upload a release" - @echo "sdist - package" - -clean: clean-build clean-pyc - -clean-build: - rm -fr build/ - rm -fr dist/ - rm -fr *.egg-info - -clean-pyc: - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '*~' -exec rm -f {} + - -lint: - flake8 progressbar tests - -test: - py.test - -test-all: - tox - -coverage: - coverage run --source progressbar setup.py test - coverage report -m - coverage html - open htmlcov/index.html - -docs: - rm -f docs/progressbar.rst - rm -f docs/modules.rst - sphinx-apidoc -o docs/ progressbar - $(MAKE) -C docs clean - $(MAKE) -C docs html - open docs/_build/html/index.html - -release: clean - python setup.py register || true - python setup.py sdist upload build_sphinx upload_sphinx - -sdist: clean - python setup.py sdist - ls -l dist diff --git a/README.rst b/README.rst index a0543eb4..ec4f7b9a 100644 --- a/README.rst +++ b/README.rst @@ -2,14 +2,15 @@ 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 ****************************************************************************** @@ -19,13 +20,17 @@ 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 ****************************************************************************** @@ -33,26 +38,35 @@ Introduction A text progress bar is typically used to display the progress of a long running operation, providing a visual cue that processing is underway. +The progressbar is based on the old Python progressbar package that was published on the now defunct Google Code. Since that project was completely abandoned by its developer and the developer did not respond to email, I decided to fork the package. This package is still backwards compatible with the original progressbar package so you can safely use it as a drop-in replacement for existing project. + The ProgressBar class manages the current progress, and the format of the line is given by a number of widgets. A widget is an object that may display differently depending on the state of the progress bar. There are many types of widgets: - - `Timer` - - `ETA` - - `AdaptiveETA` - - `FileTransferSpeed` - - `AdaptiveTransferSpeed` - - `AnimatedMarker` - - `Counter` - - `Percentage` - - `FormatLabel` - - `SimpleProgress` - - `Bar` - - `ReverseBar` - - `BouncingBar` - - `RotatingMarker` - - `DynamicMessage` + - `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. @@ -61,25 +75,24 @@ automatically enable features like auto-resizing when the system supports it. Known issues ****************************************************************************** -Due to limitations in both the IDLE shell and the Jetbrains (Pycharm) shells this progressbar cannot function properly within those. - -- The IDLE editor doesn't support these types of progress bars at all: http://bugs.python.org/issue23220 -- The Jetbrains (Pycharm) editors partially work but break with fast output. As a workaround make sure you only write to either `sys.stdout` (regular print) or `sys.stderr` at the same time. If you do plan to use both, make sure you wait about ~200 milliseconds for the next output or it will break regularly. Linked issue: https://github.com/WoLpH/python-progressbar/issues/115 +- 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 - - http://progressbar-2.readthedocs.org/en/latest/ + - https://progressbar-2.readthedocs.org/en/latest/ * Source - https://github.com/WoLpH/python-progressbar -* Bug reports +* Bug reports - https://github.com/WoLpH/python-progressbar/issues * Package homepage - https://pypi.python.org/pypi/progressbar2 * My blog - - http://w.wol.ph/ + - https://w.wol.ph/ ****************************************************************************** Usage @@ -92,12 +105,11 @@ Wrapping an iterable ============================================================================== .. code:: python - import time - import progressbar + import time + import progressbar - bar = progressbar.ProgressBar() - for i in bar(range(100)): - time.sleep(0.02) + for i in progressbar.progressbar(range(100)): + time.sleep(0.02) Progressbars with logging ============================================================================== @@ -111,8 +123,16 @@ 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 + + # 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. @@ -126,11 +146,42 @@ In most cases the following will work as well, as long as you initialize the progressbar.streams.wrap_stderr() logging.basicConfig() - bar = progressbar.ProgressBar() - for i in bar(range(10)): + 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 @@ -150,11 +201,9 @@ Combining progressbars with print output import time import progressbar - bar = progressbar.ProgressBar(redirect_stdout=True) - for i in range(100): - print 'Some text', i + for i in progressbar.progressbar(range(100), redirect_stdout=True): + print('Some text', i) time.sleep(0.1) - bar.update(i) Progressbar with unknown length ============================================================================== @@ -175,11 +224,130 @@ Bar with custom widgets import time import progressbar - bar = progressbar.ProgressBar(widgets=[ + widgets=[ ' [', progressbar.Timer(), '] ', progressbar.Bar(), ' (', progressbar.ETA(), ') ', - ]) - for i in bar(range(20)): + ] + 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) + + + for bar in bars: + Worker(bar).start() + + print() diff --git a/appveyor.yml b/appveyor.yml index 71450dab..fa5f5a53 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -6,15 +6,15 @@ environment: - PYTHON: "C:\\Python27" TOX_ENV: "py27" - - PYTHON: "C:\\Python33" - TOX_ENV: "py33" - - PYTHON: "C:\\Python34" TOX_ENV: "py34" - PYTHON: "C:\\Python35" TOX_ENV: "py35" + - PYTHON: "C:\\Python36" + TOX_ENV: "py36" + init: - "%PYTHON%/python -V" 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 fc878bf2..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,10 +11,9 @@ # # 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 @@ -39,21 +39,8 @@ 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', - 'changelog' ] -# Monkey patch to disable nonlocal image warning -import sphinx -if hasattr(sphinx, 'environment'): - original_warn_mode = sphinx.environment.BuildEnvironment.warn_node - - def allow_nonlocal_image_warn_node(self, msg, *args, **kwargs): - if not msg.startswith('nonlocal image URI found:'): - original_warn_mode(self, msg, *args, **kwargs) - - sphinx.environment.BuildEnvironment.warn_node = \ - allow_nonlocal_image_warn_node - suppress_warnings = [ 'image.nonlocal_uri', ] @@ -72,21 +59,18 @@ def allow_nonlocal_image_warn_node(self, msg, *args, **kwargs): 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, - metadata.__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 = metadata.__version__ +version: str = metadata.__version__ # The full version, including alpha/beta/rc tags. -release = metadata.__version__ +release: str = metadata.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -203,7 +187,7 @@ def allow_nonlocal_image_warn_node(self, msg, *args, **kwargs): # 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 -------------------------------------------- @@ -211,19 +195,22 @@ def allow_nonlocal_image_warn_node(self, msg, *args, **kwargs): 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, - metadata.__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 @@ -251,9 +238,14 @@ def allow_nonlocal_image_warn_node(self, msg, *args, **kwargs): # 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, - [metadata.__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. @@ -265,10 +257,16 @@ def allow_nonlocal_image_warn_node(self, msg, *args, **kwargs): # 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, - metadata.__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. @@ -287,10 +285,10 @@ def allow_nonlocal_image_warn_node(self, msg, *args, **kwargs): # -- Options for Epub output --------------------------------------------- # Bibliographic Dublin Core info. -epub_title = project -epub_author = metadata.__author__ -epub_publisher = metadata.__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. @@ -316,7 +314,7 @@ def allow_nonlocal_image_warn_node(self, msg, *args, **kwargs): # 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 = [] @@ -343,4 +341,6 @@ def allow_nonlocal_image_warn_node(self, msg, *args, **kwargs): # 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/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 162d5a52..58b16d58 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,23 +2,21 @@ Welcome to Progress Bar's documentation! ======================================== -.. include:: ../README.rst - -******** -Contents -******** - .. toctree:: :maxdepth: 4 + usage examples contributing installation + progressbar.shortcuts progressbar.bar progressbar.base - progressbar.six progressbar.utils progressbar.widgets + history + +.. include:: ../README.rst ****************** Indices and tables 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 index 971fa84e..7b7a0a39 100644 --- a/docs/progressbar.bar.rst +++ b/docs/progressbar.bar.rst @@ -5,3 +5,4 @@ progressbar.bar module :members: :undoc-members: :show-inheritance: + :member-order: bysource 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 new file mode 100644 index 00000000..674f6b64 --- /dev/null +++ b/docs/progressbar.rst @@ -0,0 +1,31 @@ +progressbar package +=================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + 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.six.rst b/docs/progressbar.six.rst deleted file mode 100644 index b4213402..00000000 --- a/docs/progressbar.six.rst +++ /dev/null @@ -1,7 +0,0 @@ -progressbar.six module -====================== - -.. automodule:: progressbar.six - :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/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 f7efb6ae..b07cf86f 100644 --- a/examples.py +++ b/examples.py @@ -1,109 +1,335 @@ #!/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 import progressbar -examples = [] - -non_interactive_sleep_factor = 100 - - -def sleep(delay): - '''Make non-interactive examples faster by a factor''' - if __name__ != '__main__': - delay /= non_interactive_sleep_factor - time.sleep(delay) +examples: list[typing.Callable[[typing.Any], typing.Any]] = [] def example(fn): - '''Wrap the examples so they generate readable output''' + """Wrap the examples so they generate readable output""" @functools.wraps(fn) - def wrapped(): + def wrapped(*args, **kwargs): try: - sys.stdout.write('Running: %s\n' % fn.__name__) - fn() + 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 - sleep(0.2) + time.sleep(0.2) examples.append(wrapped) return wrapped @example -def with_example_without_stdout_redirection(): - with progressbar.ProgressBar(max_value=10) as progress: - for i in range(10): - if i % 3 == 0: - print('Some print statement %i' % i) - # do something - sleep(0.1) - 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 with_example_stdout_redirection(): +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 = f'Bar #{i:d}' + bar_labels.append(bar_label) + assert multibar[bar_label] is not None + + 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_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) + print(f'Some print statement {i:d}') # do something p.update(i) - sleep(0.1) + time.sleep(0.1) @example -def basic_widget_example(): +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 - sleep(0.1) + time.sleep(0.1) bar.update(i + 1) bar.finish() @example -def file_transfer_example(): +def color_bar_example() -> None: widgets = [ - 'Test: ', progressbar.Percentage(), - ' ', progressbar.Bar(marker=progressbar.RotatingMarker()), - ' ', progressbar.ETA(), - ' ', progressbar.FileTransferSpeed(), + '\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 + time.sleep(0.1) + bar.update(i + 1) + bar.finish() + + +@example +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() + + +@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) + + +@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(): +def custom_file_transfer_example() -> None: class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): - - "It's bigger between 45 and 80 percent" + """ + 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) + self, bar + ) else: return progressbar.FileTransferSpeed.update(self, bar) widgets = [ CrazyFileTransferSpeed(), - ' <<<', progressbar.Bar(), '>>> ', + ' <<<', + progressbar.Bar(), + '>>> ', progressbar.Percentage(), ' ', progressbar.ETA(), @@ -113,351 +339,443 @@ def update(self, bar): bar.start() for i in range(200): # do something + time.sleep(0.01) bar.update(5 * i + 1) bar.finish() @example -def double_bar_example(): +def double_bar_example() -> None: widgets = [ - progressbar.Bar('>'), ' ', - progressbar.ETA(), ' ', + progressbar.Bar('>'), + ' ', + progressbar.ETA(), + ' ', progressbar.ReverseBar('<'), ] 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) - sleep(0.01) bar.finish() @example -def basic_file_transfer(): +def basic_file_transfer() -> None: widgets = [ - 'Test: ', progressbar.Percentage(), - ' ', progressbar.Bar(marker='0', left='[', right=']'), - ' ', progressbar.ETA(), - ' ', progressbar.FileTransferSpeed(), + '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): - sleep(0.1) + time.sleep(0.1) bar.update(i) bar.finish() @example -def simple_progress(): +def simple_progress() -> None: bar = progressbar.ProgressBar( widgets=[progressbar.SimpleProgress()], max_value=17, ).start() for i in range(17): - sleep(0.1) + time.sleep(0.1) bar.update(i + 1) bar.finish() @example -def basic_progress(): +def basic_progress() -> None: bar = progressbar.ProgressBar().start() for i in range(10): - sleep(0.1) + time.sleep(0.1) bar.update(i + 1) bar.finish() @example -def progress_with_automatic_max(): +def progress_with_automatic_max() -> None: # Progressbar can guess max_value automatically. bar = progressbar.ProgressBar() - for i in bar(range(8)): - sleep(0.1) + for _ in bar(range(8)): + time.sleep(0.1) @example -def progress_with_unavailable_max(): +def progress_with_unavailable_max() -> None: # Progressbar can't guess max_value. bar = progressbar.ProgressBar(max_value=8) - for i in bar((i for i in range(8))): - sleep(0.1) + for _ in bar(i for i in range(8)): + time.sleep(0.1) + + +@example +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 animated_marker(): +def filling_bar_animated_marker() -> None: bar = progressbar.ProgressBar( - widgets=['Working: ', progressbar.AnimatedMarker()]) - for i in bar((i for i in range(5))): - sleep(0.1) + widgets=[ + progressbar.Bar( + marker=progressbar.AnimatedMarker(fill='#'), + ), + ] + ) + for _ in bar(range(15)): + time.sleep(0.1) @example -def counter_and_timer(): - widgets = ['Processed: ', progressbar.Counter(), - ' lines (', progressbar.Timer(), ')'] +def counter_and_timer() -> None: + widgets = [ + 'Processed: ', + progressbar.Counter('Counter: %(value)05d'), + ' lines (', + progressbar.Timer(), + ')', + ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(15))): - sleep(0.1) + for _ in bar(i for i in range(15)): + time.sleep(0.1) @example -def format_label(): - widgets = [progressbar.FormatLabel( - 'Processed: %(value)d lines (in: %(elapsed)s)')] +def format_label() -> None: + widgets = [ + progressbar.FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)') + ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(15))): - sleep(0.1) + for _ in bar(i for i in range(15)): + time.sleep(0.1) @example -def animated_balloons(): +def animated_balloons() -> None: widgets = ['Balloon: ', progressbar.AnimatedMarker(markers='.oO@* ')] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(24))): - sleep(0.1) + for _ in bar(i for i in range(24)): + time.sleep(0.1) @example -def animated_arrows(): +def animated_arrows() -> None: # You may need python 3.x to see this correctly try: widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='←↖↑↗→↘↓↙')] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(24))): - sleep(0.1) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def animated_filled_arrows(): +def animated_filled_arrows() -> None: # You may need python 3.x to see this correctly try: widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='◢◣◤◥')] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(24))): - sleep(0.1) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def animated_wheels(): +def animated_wheels() -> None: # You may need python 3.x to see this correctly try: widgets = ['Wheels: ', progressbar.AnimatedMarker(markers='◐◓◑◒')] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(24))): - sleep(0.1) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def format_label_bouncer(): +def format_label_bouncer() -> None: widgets = [ progressbar.FormatLabel('Bouncer: value %(value)d - '), progressbar.BouncingBar(), ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(100))): - sleep(0.01) + for _ in bar(i for i in range(100)): + time.sleep(0.01) @example -def format_label_rotating_bouncer(): - widgets = [progressbar.FormatLabel('Animated Bouncer: value %(value)d - '), - progressbar.BouncingBar(marker=progressbar.RotatingMarker())] +def format_label_rotating_bouncer() -> None: + widgets = [ + progressbar.FormatLabel('Animated Bouncer: value %(value)d - '), + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar((i for i in range(18))): - sleep(0.1) + for _ in bar(i for i in range(18)): + time.sleep(0.1) @example -def with_right_justify(): - with progressbar.ProgressBar(max_value=10, term_width=20, - left_justify=False) as progress: +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 exceeding_maximum(): - with progressbar.ProgressBar(max_value=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 reaching_maximum(): +def reaching_maximum() -> None: progress = progressbar.ProgressBar(max_value=1) - try: + with contextlib.suppress(RuntimeError): progress.update(1) - except RuntimeError: - pass @example -def stdout_redirection(): +def stdout_redirection() -> None: with progressbar.ProgressBar(redirect_stdout=True) as progress: print('', file=sys.stdout) progress.update(0) @example -def stderr_redirection(): +def stderr_redirection() -> None: with progressbar.ProgressBar(redirect_stderr=True) as progress: print('', file=sys.stderr) progress.update(0) @example -def negative_maximum(): - try: - with progressbar.ProgressBar(max_value=-1) as progress: - progress.start() - except ValueError: - pass - - -@example -def rotating_bouncing_marker(): +def rotating_bouncing_marker() -> None: widgets = [progressbar.BouncingBar(marker=progressbar.RotatingMarker())] - with progressbar.ProgressBar(widgets=widgets, max_value=20, - term_width=10) as progress: + with progressbar.ProgressBar( + widgets=widgets, max_value=20, term_width=10 + ) as progress: for i in range(20): - sleep(0.1) + time.sleep(0.1) progress.update(i) - widgets = [progressbar.BouncingBar(marker=progressbar.RotatingMarker(), - fill_left=False)] - with progressbar.ProgressBar(widgets=widgets, max_value=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): - sleep(0.1) + time.sleep(0.1) progress.update(i) @example -def incrementing_bar(): - bar = progressbar.ProgressBar(widgets=[ - progressbar.Percentage(), - progressbar.Bar(), - ], max_value=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 - sleep(0.1) + time.sleep(0.1) bar += 1 bar.finish() @example -def increment_bar_with_output_redirection(): +def increment_bar_with_output_redirection() -> None: widgets = [ - 'Test: ', progressbar.Percentage(), - ' ', progressbar.Bar(marker=progressbar.RotatingMarker()), - ' ', progressbar.ETA(), - ' ', progressbar.FileTransferSpeed(), + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), ] - bar = progressbar.ProgressBar(widgets=widgets, max_value=1000, - redirect_stdout=True).start() - for i in range(100): + bar = progressbar.ProgressBar( + widgets=widgets, max_value=100, redirect_stdout=True + ).start() + for i in range(10): # do something - sleep(0.01) + time.sleep(0.01) bar += 10 print('Got', i) bar.finish() @example -def eta_types_demonstration(): +def eta_types_demonstration() -> None: widgets = [ progressbar.Percentage(), - ' ETA: ', progressbar.ETA(), - ' Adaptive ETA: ', progressbar.AdaptiveETA(), - ' Absolute ETA: ', progressbar.AbsoluteETA(), - ' Adaptive Transfer Speed: ', progressbar.AdaptiveTransferSpeed(), - ' ', progressbar.Bar(), + ' 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): if i < 100: - sleep(0.02) + time.sleep(0.02) elif i > 400: - sleep(0.1) + time.sleep(0.1) else: - sleep(0.01) + time.sleep(0.01) bar.update(i + 1) bar.finish() @example -def adaptive_eta_without_value_change(): +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 = progressbar.ProgressBar( + widgets=[ + progressbar.AdaptiveETA(), + progressbar.AdaptiveTransferSpeed(), + ], + max_value=2, + poll_interval=0.0001, + ) bar.start() - for i in range(100): + for _ in range(100): bar.update(1) - sleep(0.1) + time.sleep(0.1) bar.finish() @example -def iterator_with_max_value(): +def iterator_with_max_value() -> None: # Testing using progressbar as an iterator with a max value bar = progressbar.ProgressBar() - for n in bar(iter(range(100)), 100): + for _ in bar(iter(range(100)), 100): # iter range is a way to get an iterator in both python 2 and 3 - pass + time.sleep(0.01) @example -def eta(): +def eta() -> None: widgets = [ - 'Test: ', progressbar.Percentage(), - ' | ETA: ', progressbar.ETA(), - ' | AbsoluteETA: ', progressbar.AbsoluteETA(), - ' | AdaptiveETA: ', progressbar.AdaptiveETA(), + '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): - sleep(0.1) + time.sleep(0.1) bar.update(i + 1) bar.finish() @example -def dynamic_message(): - # Use progressbar.DynamicMessage to keep track of some parameter(s) during +def variables() -> None: + # Use progressbar.Variable to keep track of some parameter(s) during # your calculations widgets = [ progressbar.Percentage(), progressbar.Bar(), - progressbar.DynamicMessage('loss'), + 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) - - -@example -def format_custom_text(): + 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( @@ -466,63 +784,84 @@ def format_custom_text(): ), ) - bar = progressbar.ProgressBar(widgets=[ - format_custom_text, - ' :: ', - progressbar.Percentage(), - ]) + bar = progressbar.ProgressBar( + widgets=[ + format_custom_text, + ' :: ', + progressbar.Percentage(), + ] + ) for i in bar(range(25)): format_custom_text.update_mapping(eggs=i * 2) - sleep(0.1) + time.sleep(0.1) @example -def simple_api_example(): +def simple_api_example() -> None: bar = progressbar.ProgressBar(widget_kwargs=dict(fill='█')) - for i in bar(range(200)): - sleep(0.02) + for _ in bar(range(200)): + time.sleep(0.02) @example -def ETA_on_generators(): +def eta_on_generators(): def gen(): - for x in range(200): + for _ in range(200): yield None - widgets = [progressbar.AdaptiveETA(), ' ', - progressbar.ETA(), ' ', - progressbar.Timer()] + widgets = [ + progressbar.AdaptiveETA(), + ' ', + progressbar.ETA(), + ' ', + progressbar.Timer(), + ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar(gen()): - sleep(0.02) + for _ in bar(gen()): + time.sleep(0.02) @example def percentage_on_generators(): def gen(): - for x in range(200): + for _ in range(200): yield None - widgets = [progressbar.Counter(), ' ', - progressbar.Percentage(), ' ', - progressbar.SimpleProgress(), ' '] + widgets = [ + progressbar.Counter(), + ' ', + progressbar.Percentage(), + ' ', + progressbar.SimpleProgress(), + ' ', + ] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar(gen()): - sleep(0.02) - - -def test(*tests): - for example in examples: - if not tests or example.__name__ in tests: + 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() - else: - print('Skipping', example.__name__) 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 index ddb85540..785fff86 100644 --- a/progressbar/__about__.py +++ b/progressbar/__about__.py @@ -1,4 +1,4 @@ -'''Text progress bar library for Python. +"""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. @@ -9,17 +9,19 @@ 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__ = ' '.join(''' +__description__: str = ' '.join( + """ A Python Progressbar library to provide visual (yet text based) progress to long running operations. -'''.strip().split()) +""".strip().split(), +) __email__ = 'wolph@wol.ph' -__version__ = 'v3.31.0' +__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 7baca0e0..cf4de765 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -1,68 +1,91 @@ from datetime import date -from .utils import streams - +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 ( - Timer, ETA, - AdaptiveETA, AbsoluteETA, - DataSize, - FileTransferSpeed, + AdaptiveETA, AdaptiveTransferSpeed, AnimatedMarker, - Counter, - Percentage, - FormatLabel, - SimpleProgress, Bar, - ReverseBar, BouncingBar, - RotatingMarker, + Counter, + CurrentTime, + DataSize, DynamicMessage, + FileTransferSpeed, FormatCustomText, - CurrentTime -) - -from .bar import ( - ProgressBar, - DataTransferBar, - NullBar, -) -from .base import UnknownLength - - -from .__about__ import ( - __author__, - __version__, + FormatLabel, + FormatLabelBar, + GranularBar, + JobStatusBar, + MultiProgressBar, + MultiRangeBar, + Percentage, + PercentageLabelBar, + ReverseBar, + RotatingMarker, + SimpleProgress, + SmoothingETA, + Timer, + Variable, + VariableMixin, ) __date__ = str(date.today()) __all__ = [ - 'streams', - 'Timer', 'ETA', - 'AdaptiveETA', 'AbsoluteETA', - 'DataSize', - 'FileTransferSpeed', + 'AdaptiveETA', 'AdaptiveTransferSpeed', 'AnimatedMarker', - 'Counter', - 'Percentage', - 'FormatLabel', - 'SimpleProgress', 'Bar', - 'ReverseBar', 'BouncingBar', - 'UnknownLength', - 'ProgressBar', + 'Counter', + 'CurrentTime', + 'DataSize', 'DataTransferBar', - 'RotatingMarker', + 'DoubleExponentialMovingAverage', 'DynamicMessage', + 'ExponentialMovingAverage', + 'FileTransferSpeed', 'FormatCustomText', - 'CurrentTime', + 'FormatLabel', + 'FormatLabelBar', + 'GranularBar', + 'JobStatusBar', + 'LineOffsetStreamWrapper', + 'MultiBar', + 'MultiProgressBar', + 'MultiRangeBar', 'NullBar', + 'Percentage', + 'PercentageLabelBar', + 'ProgressBar', + 'ReverseBar', + 'RotatingMarker', + 'SimpleProgress', + 'SmoothingAlgorithm', + 'SmoothingETA', + 'SortKey', + 'Timer', + 'UnknownLength', + 'Variable', + 'VariableMixin', '__author__', '__version__', + 'len_color', + 'progressbar', + 'streams', ] 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..c0cb7a1f --- /dev/null +++ b/progressbar/algorithms.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import abc +import typing +from datetime import timedelta + + +class SmoothingAlgorithm(abc.ABC): + @abc.abstractmethod + def __init__(self, **kwargs: typing.Any): + raise NotImplementedError + + @abc.abstractmethod + def update(self, new_value: float, elapsed: timedelta) -> float: + """Updates the algorithm with a new value and returns the smoothed + value. + """ + raise NotImplementedError + + +class ExponentialMovingAverage(SmoothingAlgorithm): + """ + The Exponential Moving Average (EMA) is an exponentially weighted moving + average that reduces the lag that's typically associated with a simple + moving average. It's more responsive to recent changes in data. + """ + + def __init__(self, alpha: float = 0.5) -> None: + self.alpha = alpha + self.value = 0 + + def update(self, new_value: float, elapsed: timedelta) -> float: + self.value = self.alpha * new_value + (1 - self.alpha) * self.value + return self.value + + +class DoubleExponentialMovingAverage(SmoothingAlgorithm): + """ + The Double Exponential Moving Average (DEMA) is essentially an EMA of an + EMA, which reduces the lag that's typically associated with a simple EMA. + It's more responsive to recent changes in data. + """ + + def __init__(self, alpha: float = 0.5) -> None: + self.alpha = alpha + self.ema1 = 0 + self.ema2 = 0 + + def update(self, new_value: float, elapsed: timedelta) -> float: + self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1 + self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2 + return 2 * self.ema1 - self.ema2 diff --git a/progressbar/bar.py b/progressbar/bar.py index 18fcee7c..29f0cafa 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -1,116 +1,458 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals -from __future__ import with_statement - -import sys +""" +This module provides core classes and mixins for rendering progress bars. + +It includes the main `ProgressBar` class and mixins for handling file descriptors, +resizing, and standard output redirection. It also defines base classes for progress +bar implementations and utility functions for formatting the progress bar display. +""" +from __future__ import annotations + +import abc +import contextlib +import itertools +import logging import math +import os +import sys import time -import logging +import timeit +import typing import warnings -from datetime import datetime, timedelta -import collections +from copy import deepcopy +from datetime import datetime +from types import FrameType -from python_utils import converters +from python_utils import converters, types -from . import widgets -from . import widgets as widgets_module # Avoid name collision -from . import six -from . import base -from . import utils +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, 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 -class ProgressBarMixinBase(object): + 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 - def __init__(self, **kwargs): - pass + last_update_time = property(get_last_update_time, set_last_update_time) - def start(self, **kwargs): + def __init__(self, **kwargs: typing.Any): # noqa: B027 pass - def update(self, value=None): + def start(self, **kwargs: typing.Any): + self._started = True + + def update(self, value: ValueT = None): # noqa: B027 pass def finish(self): # pragma: no cover - pass + 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__ -class ProgressBarBase(collections.Iterable, ProgressBarMixinBase): - pass + def data(self) -> types.Dict[str, types.Any]: # pragma: no cover + raise NotImplementedError() + def started(self) -> bool: + return self._finished or self._started -class DefaultFdMixin(ProgressBarMixinBase): + 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 __init__(self, fd=sys.stderr, **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 - ProgressBarMixinBase.__init__(self, **kwargs) + 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. - def update(self, *args, **kwargs): + 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 = converters.to_unicode('\r' + self._format_line()) - self.fd.write(line) - def finish(self, *args, **kwargs): # pragma: no cover + 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: + + 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())) -class ResizableMixin(ProgressBarMixinBase): + 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() - def __init__(self, term_width=None, **kwargs): + 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: - try: + else: # pragma: no cover + with contextlib.suppress(Exception): self._handle_resize() import signal - self._prev_handle = signal.getsignal(signal.SIGWINCH) - signal.signal(signal.SIGWINCH, self._handle_resize) - self.signal_set = True - except Exception: # pragma: no cover - pass - def _handle_resize(self, signum=None, frame=None): - 'Tries to catch resize signals sent from the terminal.' + 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: - try: + with contextlib.suppress(Exception): import signal - signal.signal(signal.SIGWINCH, self._prev_handle) - except Exception: # pragma no cover - pass + signal.signal( + signal.SIGWINCH, + self._prev_handle, # type: ignore + ) -class StdRedirectMixin(DefaultFdMixin): - def __init__(self, redirect_stderr=False, redirect_stdout=False, **kwargs): +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, **kwargs): + def start(self, *args: typing.Any, **kwargs: typing.Any): if self.redirect_stdout: utils.streams.wrap_stdout() @@ -123,16 +465,19 @@ def start(self, *args, **kwargs): self.stdout = utils.streams.stdout self.stderr = utils.streams.stderr + utils.streams.start_capturing(self) DefaultFdMixin.start(self, *args, **kwargs) - def update(self, value=None): - self.fd.write('\r' + ' ' * self.term_width + '\r') + 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.flush() + utils.streams.stop_capturing(self) if self.redirect_stdout: utils.streams.unwrap_stdout() @@ -140,17 +485,55 @@ def finish(self, end='\n'): utils.streams.unwrap_stderr() -class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase): - - '''The ProgressBar class which updates and prints the bar. +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) + ... progress.update(i + 1) ... # do something - ... >>> progress.finish() You can also use a ProgressBar as an iterator: @@ -160,7 +543,6 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase): >>> 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 @@ -177,88 +559,144 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase): 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): - - value: current progress (min_value <= value <= max_value) - - max_value: maximum (and final) value - - end_time: not None 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 - ''' - - _DEFAULT_MAXVAL = 100 - _MINIMUM_UPDATE_INTERVAL = 0.05 # update up to a 20 times per second - - def __init__(self, min_value=0, max_value=None, widgets=None, - left_justify=True, initial_value=0, poll_interval=None, - widget_kwargs=None, - **kwargs): - ''' - Initializes a progress bar with sane defaults - - 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 time. Note that this - is always limited by - `_MINIMUM_UPDATE_INTERVAL` - widget_kwargs (dict): The default keyword arguments for widgets - ''' + """ + + _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) + 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) + warnings.warn( + 'The usage of `poll` is deprecated, please use ' + '`poll_interval` instead', + DeprecationWarning, + stacklevel=1, + ) poll_interval = kwargs.get('poll') - if max_value: - if min_value > max_value: - raise ValueError('Max value needs to be bigger than the min ' - 'value') + 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 - self.max_value = max_value - self.widgets = widgets + # 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.value = initial_value self.last_update_time = None self.start_time = None self.updates = 0 self.end_time = None self.extra = dict() - - if poll_interval and isinstance(poll_interval, (int, float)): - poll_interval = timedelta(seconds=poll_interval) - - # Note that the _MINIMUM_UPDATE_INTERVAL sets the minimum in case of - # low values. - self.poll_interval = poll_interval - - # A dictionary of names of DynamicMessage's - self.dynamic_messages = {} - for widget in (self.widgets or []): - if isinstance(widget, widgets_module.DynamicMessage): - self.dynamic_messages[widget.name] = None + self._last_update_timer = timeit.default_timer() @property - def percentage(self): - '''Return current percentage, returns None if no max_value is given + def percentage(self) -> float | None: + """Return current percentage, returns None if no max_value is given. >>> progress = ProgressBar() >>> progress.max_value = 10 @@ -287,50 +725,52 @@ def percentage(self): 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 - percentage = todo / total + total = self.max_value - self.min_value # type: ignore + percentage = 100.0 * todo / total else: - percentage = 1 - - return percentage * 100 - - def get_last_update_time(self): - if self._last_update_time: - return datetime.fromtimestamp(self._last_update_time) - - def set_last_update_time(self, value): - 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 data(self): - ''' - 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 - - dynamic_messages: A dictionary of user-defined DynamicMessage's - ''' + 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() - elapsed = self.last_update_time - self.start_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.timedelta_to_seconds(elapsed) + total_seconds_elapsed = utils.deltas_to_seconds(elapsed) return dict( # The maximum value (can be None with iterators) max_value=self.max_value, @@ -349,8 +789,8 @@ def data(self): # 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.), + 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 @@ -361,41 +801,49 @@ def data(self): time_elapsed=elapsed, # Percentage as a float or `None` if no max_value is available percentage=self.percentage, - # Dictionary of DynamicMessage's - dynamic_messages=self.dynamic_messages + # 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: - self.widget_kwargs.setdefault( - 'samples', max(10, self.max_value / 100)) - return [ widgets.Percentage(**self.widget_kwargs), - ' ', widgets.SimpleProgress( - format='(%s)' % widgets.SimpleProgress.DEFAULT_FORMAT, - **self.widget_kwargs), - ' ', widgets.Bar(**self.widget_kwargs), - ' ', widgets.Timer(**self.widget_kwargs), - ' ', widgets.AdaptiveETA(**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.Counter(**self.widget_kwargs), - ' ', widgets.Timer(**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 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: - if self.max_value is None: - self.max_value = base.UnknownLength - else: - self.max_value = max_value + except TypeError: # pragma: no cover + self.max_value = base.UnknownLength self._iterable = iter(iterable) return self @@ -404,236 +852,304 @@ def __iter__(self): return self def __next__(self): + value: typing.Any try: - value = next(self._iterable) + 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) - return value + 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() + self.finish(dirty=bool(exc_type)) def __enter__(self): - return self.start() + 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.' - self.update(self.value + value) - return self - - def _format_widgets(self): - result = [] - expanding = [] - width = self.term_width - data = self.data() - - for index, widget in enumerate(self.widgets): - if isinstance(widget, widgets.AutoWidthWidgetBase): - result.append(widget) - expanding.insert(0, index) - elif isinstance(widget, six.basestring): - result.append(widget) - width -= len(widget) - else: - widget_output = converters.to_unicode(widget(self, data)) - result.append(widget_output) - width -= len(widget_output) - - count = len(expanding) - while expanding: - portion = max(int(math.ceil(width * 1. / count)), 0) - index = expanding.pop() - widget = result[index] - count -= 1 - - widget_output = widget(self, data, portion) - width -= len(widget_output) - result[index] = widget_output - - return result - - @classmethod - def _to_unicode(cls, args): - for arg in args: - yield converters.to_unicode(arg) - - def _format_line(self): - 'Joins the widgets and justifies the line' - - widgets = ''.join(self._to_unicode(self._format_widgets())) + "Updates the ProgressBar by adding a new value." + return self.increment(value) - if self.left_justify: - return widgets.ljust(self.term_width) - else: - return widgets.rjust(self.term_width) + 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.poll_interval: - delta = datetime.now() - self.last_update_time - poll_status = delta > self.poll_interval - else: - poll_status = False + "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 - # Do not update if value increment is not large enough to + # Update if value increment is not large enough to # add more bars to progressbar (according to current # terminal width) - try: - divisor = self.max_value / self.term_width # float division - if self.value // divisor == self.previous_value // divisor: - return poll_status or self.end_time - except Exception: - # ignore any division errors - pass - - return self.value > self.next_update or poll_status or self.end_time - - def update(self, value=None, force=False, **kwargs): - 'Updates the ProgressBar to a new value.' + 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() - return self.update(value, force=force, **kwargs) - if value is not None and value is not base.UnknownLength: + 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 <= self.max_value: - # Correct value, let's accept - pass - else: + elif self.min_value > value: # type: ignore raise ValueError( - 'Value %s is out of range, should be between %s and %s' - % (value, self.min_value, self.max_value)) + 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 - minimum_update_interval = self._MINIMUM_UPDATE_INTERVAL - update_delta = time.time() - self._last_update_time - if update_delta < minimum_update_interval and not force: - # Prevent updating too often - return - # Save the updated values for dynamic messages - for key in kwargs: - if key in self.dynamic_messages: - self.dynamic_messages[key] = kwargs[key] - else: - raise TypeError( - 'update() got an unexpected keyword ' + - 'argument {0!r}'.format(key)) + variables_changed = self._update_variables(kwargs) - if self._needs_update() or force: - self.updates += 1 - ResizableMixin.update(self, value=value) - ProgressBarBase.update(self, value=value) - StdRedirectMixin.update(self, value=value) + if self._needs_update() or variables_changed or force: + self._update_parents(value) - # Only flush if something was actually written - self.fd.flush() + 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=None): - '''Starts measuring time, and prints the bar at 0%. + 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) - ... + ... # do something + ... pbar.update(i + 1) >>> pbar.finish() - ''' - StdRedirectMixin.start(self, max_value=max_value) - ResizableMixin.start(self, max_value=max_value) - ProgressBarBase.start(self, max_value=max_value) + """ + 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 - self.max_value = max_value or self.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 self.widgets is None: + 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 = getattr(widget, 'INTERVAL', None) + 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, ) - self.num_intervals = max(100, self.term_width) - self.next_update = 0 - - if self.max_value is not base.UnknownLength and self.max_value < 0: - raise ValueError('Value out of range') - - self.start_time = self.last_update_time = datetime.now() - self.update(self.min_value, force=True) - - return self + def finish(self, end: str = '\n', dirty: bool = False): + """ + Puts the ProgressBar bar in the finished state. - def finish(self, end='\n'): - 'Puts the ProgressBar bar in the finished state.' + Also flushes and disables output buffering if this was the last + progressbar running. - self.end_time = datetime.now() - self.update(self.max_value, force=True) + 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. + """A progress bar with sensible defaults for downloads etc. This assumes that the values its given are numbers of bytes. - ''' - # Base class defaults to 100, but that makes no sense here - _DEFAULT_MAXVAL = base.UnknownLength + """ def default_widgets(self): if self.max_value: return [ widgets.Percentage(), - ' of ', widgets.DataSize('max_value'), - ' ', widgets.Bar(), - ' ', widgets.Timer(), - ' ', widgets.AdaptiveETA(), + ' of ', + widgets.DataSize('max_value'), + ' ', + widgets.Bar(), + ' ', + widgets.Timer(), + ' ', + widgets.SmoothingETA(), ] else: return [ widgets.AnimatedMarker(), - ' ', widgets.DataSize(), - ' ', widgets.Timer(), + ' ', + widgets.DataSize(), + ' ', + widgets.Timer(), ] class NullBar(ProgressBar): - - ''' + """ Progress bar that does absolutely nothing. Useful for single verbosity - flags - ''' + flags. + """ - def start(self, *args, **kwargs): + def start(self, *args: typing.Any, **kwargs: typing.Any): return self - def update(self, *args, **kwargs): + def update(self, *args: typing.Any, **kwargs: typing.Any): return self - def finish(self, *args, **kwargs): + def finish(self, *args: typing.Any, **kwargs: typing.Any): return self diff --git a/progressbar/base.py b/progressbar/base.py index 2808c258..48edf18f 100644 --- a/progressbar/base.py +++ b/progressbar/base.py @@ -1,15 +1,36 @@ -from .six import with_metaclass +from __future__ import annotations + +import typing +from typing import IO, TextIO class FalseMeta(type): - def __bool__(self): # pragma: no cover + @classmethod + def __bool__(cls) -> bool: # pragma: no cover return False - def __cmp__(self, other): # pragma: no cover + @classmethod + def __cmp__(cls, other: typing.Any) -> int: # pragma: no cover return -1 __nonzero__ = __bool__ -class UnknownLength(with_metaclass(FalseMeta, object)): +class UnknownLength(metaclass=FalseMeta): + pass + + +class Undefined(metaclass=FalseMeta): pass + + +assert IO is not None +assert TextIO is not None + +__all__ = ( + 'IO', + 'FalseMeta', + 'TextIO', + 'Undefined', + 'UnknownLength', +) 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..934798c3 --- /dev/null +++ b/progressbar/multi.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import enum +import io +import itertools +import operator +import sys +import threading +import time +import timeit +import types +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 _Update(typing.Protocol): + def __call__(self, force: bool = True, write: bool = True) -> str: ... + + +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(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: str = '{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: typing.Any, + ): + 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 # pyright: ignore[reportPrivateUsage] + ) + + super().__setitem__(key, bar) + + def __delitem__(self, key: str) -> None: + """Remove a progressbar from the multibar.""" + bar_: bar.ProgressBar = self.pop(key) + self._finished_at.pop(bar_, None) + self._labeled.discard(bar_) + + def __getitem__(self, key: str): + """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) -> None: + 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) -> None: + """Render the multibar to the given stream.""" + now: float = timeit.default_timer() + expired: float | None = ( + 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: float, + expired: float | None, + ) -> typing.Iterable[str]: + def update( + force: bool = True, write: bool = True + ) -> str: # pragma: no cover + self._label_bar(bar_) + bar_.update(force=force) + if write: + return typing.cast(stream.LastLineStream, bar_.fd).line + else: + return '' + + 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() + yield update() + else: + yield self.initial_format.format(label=bar_.label) + + def _render_finished_bar( + self, + bar_: bar.ProgressBar, + now: float, + expired: float | None, + update: _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: typing.Any, + end: str = '\n', + offset: int | None = None, + flush: bool = True, + clear: bool = True, + **kwargs: typing.Any, + ): + """ + 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) -> None: + self.fd.write(self._buffer.getvalue()) + self._buffer.truncate(0) + self.fd.flush() + + def run(self, join: bool = True) -> None: + """ + 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) -> None: + 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: float | None = None) -> 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: type[BaseException] | None, + exc_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> bool | None: + 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..220c8f23 --- /dev/null +++ b/progressbar/shortcuts.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import typing + +from . import ( + bar, + widgets as widgets_module, +) + +T = typing.TypeVar('T') + + +def progressbar( + iterator: typing.Iterator[T], + min_value: bar.NumberT = 0, + max_value: bar.ValueT = None, + widgets: typing.Sequence[widgets_module.WidgetBase | str] | None = None, + prefix: str | None = None, + suffix: str | None = None, + **kwargs: typing.Any, +) -> typing.Generator[T, None, None]: + 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/six.py b/progressbar/six.py deleted file mode 100644 index 7a5512e9..00000000 --- a/progressbar/six.py +++ /dev/null @@ -1,69 +0,0 @@ -'''Library to make differences between Python 2 and 3 transparent''' -import sys - -__all__ = [ - 'StringIO', - 'basestring', -] - -try: - from cStringIO import StringIO -except ImportError: # pragma: no cover - try: - from StringIO import StringIO - except ImportError: - from io import StringIO - -PY3 = sys.version_info[0] == 3 - -if PY3: # pragma: no cover - basestring = str, -else: # pragma: no cover - basestring = str, unicode # NOQA - - -if PY3: # pragma: no cover - numeric_types = int, float -else: # pragma: no cover - numeric_types = int, long, float # NOQA - - -if PY3: # pragma: no cover - long_int = int -else: # pragma: no cover - long_int = long # NOQA - - -# Copied from the public six library: ----------------------------------------- - -# Copyright (c) 2010-2015 Benjamin Peterson -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - - -def with_metaclass(meta, *bases): - """Create a base class with a metaclass.""" - # This requires a bit of explanation: the basic idea is to make a dummy - # metaclass for one level of class instantiation that replaces itself with - # the actual metaclass. - class metaclass(meta): - - def __new__(cls, name, this_bases, d): - return meta(name, bases, d) - return type.__new__(metaclass, 'temporary_class', (), {}) 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..e1f9543c --- /dev/null +++ b/progressbar/terminal/base.py @@ -0,0 +1,650 @@ +from __future__ import annotations + +import abc +import collections +import colorsys +import enum +import threading +import typing +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: typing.Any) -> None: + self._code = code + self._default_args = default_args + + def __call__(self, *args: typing.Any) -> str: + return self._template.format( + args=';'.join(map(str, args or self._default_args)), + code=self._code, + ) + + def __str__(self) -> str: + return self() + + +class CSINoArg(CSI): + def __call__( # pyright: ignore[reportIncompatibleMethodOverride] + self, + ) -> str: + 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: int): + return UP(n) + CLEAR_LINE_ALL() + DOWN(n) + + +# Report Cursor Position (CPR), response = [row;column] as row;columnR +class _CPR(str): # pragma: no cover # pyright: ignore[reportUnusedClass] + _response_lock = threading.Lock() + + def __call__(self, stream: typing.IO[str]) -> tuple[int, int]: + res: str = '' + + with self._response_lock: + stream.write(str(self)) + stream.flush() + + while not res.endswith('R'): + char = getch() + + if char: + 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: typing.IO[str]) -> int: + row, _ = self(stream) + return row + + def column(self, stream: typing.IO[str]) -> 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: tuple[int, int, int], + rgb2: tuple[int, int, int], + ): + 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: str) -> str: + 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(typing.NamedTuple): + """ + Red, Green, Blue color. + """ + + red: int + green: int + blue: int + + 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(typing.NamedTuple): + """ + Hue, Saturation, Lightness color. + + Hue is a value between 0 and 360, saturation and lightness are between 0(%) + and 100(%). + + """ + + hue: float + saturation: float + lightness: float + + @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): + """ + Deprecated, `typing.NamedTuple` does not allow for multiple inheritance so + this class cannot be used with type hints. + """ + + def get_color(self, value: float) -> Color: + raise NotImplementedError() + + +class Color(typing.NamedTuple): + """ + 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. + """ + + rgb: RGB + hls: HSL + name: str | None + xterm: int | None + + 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) -> str: + if self.name: + return self.name + else: + return str(self.rgb) + + 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: + if hls is None: + hls = HSL.from_rgb(rgb) + + color = Color(rgb, hls, name, xterm) + + if name: + cls.by_name[name].append(color) + cls.by_lowername[name.lower()].append(color) + + 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: + interpolate: typing.Callable[[Color, Color, float], Color] | None + colors: tuple[Color, ...] + + def __init__( + self, + *colors: Color, + interpolate: ( + typing.Callable[[Color, Color, float], Color] | None + ) = 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: str): + return text + + def __repr__(self) -> str: + return 'DummyColor()' + + +class SGR(CSI): + _start_code: int + _end_code: int + _code = 'm' + __slots__ = '_end_code', '_start_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__( # pyright: ignore[reportIncompatibleMethodOverride] + self, + text: str, + *args: typing.Any, + ) -> str: + return self._start_template + text + self._end_template + + +class SGRColor(SGR): + __slots__ = '_color', '_end_code', '_start_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..e3064b0b --- /dev/null +++ b/progressbar/terminal/stream.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import sys +import typing +from collections.abc import Iterable, Iterator +from types import TracebackType + +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 index c9aa370e..fb0c72b6 100644 --- a/progressbar/utils.py +++ b/progressbar/utils.py @@ -1,385 +1,450 @@ +from __future__ import annotations + +import atexit +import contextlib +import datetime import io +import logging import os +import re import sys -import math -import logging -import datetime +from collections.abc import Iterable, Iterator +from types import TracebackType + +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() -from . import six + return ret + def flush(self) -> None: + self.buffer.flush() -# There might be a better way to get the epoch with tzinfo, please create -# a pull request if you know a better way that functions for Python 2 and 3 -epoch = datetime.datetime(year=1970, month=1, day=1) + 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() -class StreamWrapper(object): - '''Wrap stdout and stderr globally''' + def flush_target(self) -> None: # pragma: no cover + if not self.target.closed and getattr(self.target, 'flush', None): + self.target.flush() - def __init__(self): + 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 os.environ.get('WRAP_STDOUT'): # pragma: no cover + if env.env_flag('WRAP_STDOUT', default=False): # pragma: no cover self.wrap_stdout() - if os.environ.get('WRAP_STDERR'): # pragma: no cover + if env.env_flag('WRAP_STDERR', default=False): # pragma: no cover self.wrap_stderr() - def wrap(self, stdout=False, stderr=False): + 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): + def wrap_stdout(self) -> WrappingIO: self.wrap_excepthook() if not self.wrapped_stdout: - self.stdout = sys.stdout = six.StringIO() + self.stdout = sys.stdout = WrappingIO( # type: ignore + self.original_stdout, + listeners=self.listeners, + ) self.wrapped_stdout += 1 - return sys.stdout + return sys.stdout # type: ignore - def wrap_stderr(self): + def wrap_stderr(self) -> WrappingIO: self.wrap_excepthook() if not self.wrapped_stderr: - self.stderr = sys.stderr = six.StringIO() + self.stderr = sys.stderr = WrappingIO( # type: ignore + self.original_stderr, + listeners=self.listeners, + ) self.wrapped_stderr += 1 - return sys.stderr + return sys.stderr # type: ignore - def unwrap_excepthook(self): + def unwrap_excepthook(self) -> None: if self.wrapped_excepthook: self.wrapped_excepthook -= 1 sys.excepthook = self.original_excepthook - def wrap_excepthook(self): + def wrap_excepthook(self) -> None: if not self.wrapped_excepthook: - print('wrapping excepthook') + logger.debug('wrapping excepthook') self.wrapped_excepthook += 1 sys.excepthook = self.excepthook - def unwrap(self, stdout=False, stderr=False): + def unwrap(self, stdout: bool = False, stderr: bool = False) -> None: if stdout: self.unwrap_stdout() if stderr: self.unwrap_stderr() - def unwrap_stdout(self): + 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): + 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 flush(self): - if self.wrapped_stdout: + 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.original_stdout.write(self.stdout.getvalue()) - self.stdout.seek(0) - self.stdout.truncate(0) - except (io.UnsupportedOperation, - AttributeError): # pragma: no cover + self.stdout._flush() + except io.UnsupportedOperation: # pragma: no cover self.wrapped_stdout = False - logger.warn('Disabling stdout redirection, %r is not seekable', - sys.stdout) + logger.warning( + 'Disabling stdout redirection, %r is not seekable', + sys.stdout, + ) - if self.wrapped_stderr: + if self.wrapped_stderr and isinstance(self.stderr, WrappingIO): try: - self.original_stderr.write(self.stderr.getvalue()) - self.stderr.seek(0) - self.stderr.truncate(0) - except (io.UnsupportedOperation, - AttributeError): # pragma: no cover + self.stderr._flush() + except io.UnsupportedOperation: # pragma: no cover self.wrapped_stderr = False - logger.warn('Disabling stderr redirection, %r is not seekable', - sys.stderr) - - def excepthook(self, exc_type, exc_value, exc_traceback): + 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() -streams = StreamWrapper() -logger = logging.getLogger(__name__) - - -def timedelta_to_seconds(delta): - '''Convert a timedelta to seconds with the microseconds as fraction - >>> from datetime import timedelta - >>> '%d' % timedelta_to_seconds(timedelta(days=1)) - '86400' - >>> '%d' % timedelta_to_seconds(timedelta(seconds=1)) - '1' - >>> '%.6f' % timedelta_to_seconds(timedelta(seconds=1, microseconds=1)) - '1.000001' - >>> '%.6f' % timedelta_to_seconds(timedelta(microseconds=1)) - '0.000001' - ''' - # Only convert to float if needed - if delta.microseconds: - total = delta.microseconds * 1e-6 - else: - total = 0 - total += delta.seconds - total += delta.days * 60 * 60 * 24 - return total - - -def scale_1024(x, n_prefixes): - '''Scale a number down to a suitable size, based on powers of 1024. - - Returns the scaled number and the power of 1024 used. - - Use to format numbers of bytes to KiB, MiB, etc. - - >>> scale_1024(310, 3) - (310.0, 0) - >>> scale_1024(2048, 3) - (2.0, 1) - >>> scale_1024(0, 2) - (0.0, 0) - >>> scale_1024(0.5, 2) - (0.5, 0) - >>> scale_1024(1, 2) - (1.0, 0) - ''' - if x <= 0: - power = 0 - else: - power = min(int(math.log(x, 2) / 10), n_prefixes - 1) - scaled = float(x) / (2 ** (10 * power)) - return scaled, power - - -def get_terminal_size(): # pragma: no cover - '''Get the current size of your terminal - - Multiple returns are not always a good idea, but in this case it greatly - simplifies the code so I believe it's justified. It's not the prettiest - function but that's never really possible with cross-platform code. - - Returns: - width, height: Two integers containing width and height - ''' - - try: - # Default to 79 characters for IPython notebooks - from IPython import get_ipython - ipython = get_ipython() - from ipykernel import zmqshell - if isinstance(ipython, zmqshell.ZMQInteractiveShell): - return 79, 24 - except Exception: # pragma: no cover - pass - - try: - # This works for Python 3, but not Pypy3. Probably the best method if - # it's supported so let's always try - import shutil - w, h = shutil.get_terminal_size() - if w and h: - # The off by one is needed due to progressbars in some cases, for - # safety we'll always substract it. - return w - 1, h - except Exception: # pragma: no cover - pass - - try: - w = int(os.environ.get('COLUMNS')) - h = int(os.environ.get('LINES')) - if w and h: - return w, h - except Exception: # pragma: no cover - pass - - try: - import blessings - terminal = blessings.Terminal() - w = terminal.width - h = terminal.height - if w and h: - return w, h - except Exception: # pragma: no cover - pass - - try: - w, h = _get_terminal_size_linux() - if w and h: - return w, h - except Exception: # pragma: no cover - pass - - try: - # Windows detection doesn't always work, let's try anyhow - w, h = _get_terminal_size_windows() - if w and h: - return w, h - except Exception: # pragma: no cover - pass - - try: - # needed for window's python in cygwin's xterm! - w, h = _get_terminal_size_tput() - if w and h: - return w, h - except Exception: # pragma: no cover - pass - - return 79, 24 - - -def _get_terminal_size_windows(): # pragma: no cover - res = None - try: - from ctypes import windll, create_string_buffer - - # stdin handle is -10 - # stdout handle is -11 - # stderr handle is -12 - - h = windll.kernel32.GetStdHandle(-12) - csbi = create_string_buffer(22) - res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi) - except Exception: - return None - - if res: - import struct - (_, _, _, _, _, left, top, right, bottom, _, _) = \ - struct.unpack("hhhhHhhhhhh", csbi.raw) - w = right - left - h = bottom - top - return w, h - else: - return None - - -def _get_terminal_size_tput(): # pragma: no cover - # get terminal width src: http://stackoverflow.com/questions/263890/ - try: - import subprocess - proc = subprocess.Popen( - ['tput', 'cols'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - output = proc.communicate(input=None) - w = int(output[0]) - proc = subprocess.Popen( - ['tput', 'lines'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - output = proc.communicate(input=None) - h = int(output[0]) - return w, h - except Exception: - return None - - -def _get_terminal_size_linux(): # pragma: no cover - def ioctl_GWINSZ(fd): - try: - import fcntl - import termios - import struct - size = struct.unpack( - 'hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234')) - except Exception: - return None - return size - - size = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2) - - if not size: - try: - fd = os.open(os.ctermid(), os.O_RDONLY) - size = ioctl_GWINSZ(fd) - os.close(fd) - except Exception: - pass - if not size: - try: - size = os.environ['LINES'], os.environ['COLUMNS'] - except Exception: - return None - - return int(size[1]), int(size[0]) - - -def format_time(time, precision=datetime.timedelta(seconds=1)): - '''Formats timedelta/datetime/seconds - - >>> format_time('1') - '0:00:01' - >>> format_time(1.234) - '0:00:01' - >>> format_time(1) - '0:00:01' - >>> format_time(datetime.datetime(2000, 1, 2, 3, 4, 5, 6)) - '2000-01-02 03:04:05' - >>> format_time(datetime.date(2000, 1, 2)) - '2000-01-02' - >>> format_time(datetime.timedelta(seconds=3661)) - '1:01:01' - >>> format_time(None) - '--:--:--' - >>> format_time(format_time) # doctest: +ELLIPSIS +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): - ... - TypeError: Unknown type ... - - ''' - precision_seconds = precision.total_seconds() - - if isinstance(time, six.basestring + six.numeric_types): - try: - time = datetime.timedelta(seconds=six.long_int(time)) - except OverflowError: # pragma: no cover - time = None - - if isinstance(time, datetime.timedelta): - seconds = time.total_seconds() - # Truncate the number to the given precision - seconds = seconds - (seconds % precision_seconds) - - return str(datetime.timedelta(seconds=seconds)) - elif isinstance(time, datetime.datetime): - # Python 2 doesn't have the timestamp method - seconds = timestamp(time) - # Truncate the number to the given precision - seconds = seconds - (seconds % precision_seconds) - - try: # pragma: no cover - if six.PY3: - dt = datetime.datetime.fromtimestamp(seconds) - else: - dt = datetime.datetime.utcfromtimestamp(seconds) - except ValueError: # pragma: no cover - dt = datetime.datetime.max - return str(dt) - elif isinstance(time, datetime.date): - return str(time) - elif time is None: - return '--:--:--' - else: - raise TypeError('Unknown type %s: %r' % (type(time), time)) + ... + 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 timestamp(dt): # pragma: no cover - if hasattr(dt, 'timestamp'): - return dt.timestamp() - else: - return (dt - epoch).total_seconds() + 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 d31038d9..ffb201ef 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1,27 +1,39 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals -from __future__ import with_statement +from __future__ import annotations import abc +import contextlib import datetime -import pprint -import sys +import functools +import logging +import typing -from python_utils import converters +# Ruff is being stupid and doesn't understand `ClassVar` if it comes from the +# `types` module +from typing import ClassVar -from . import base -from . import six -from . import utils +from python_utils import containers, converters, types -MAX_DATE = datetime.date(year=datetime.MAXYEAR, month=12, day=31) -MAX_TIME = datetime.time(23, 59, 59) -MAX_DATETIME = datetime.datetime.combine(MAX_DATE, MAX_TIME) +from . import algorithms, base, terminal, utils +from .terminal import colors + +if types.TYPE_CHECKING: + from .bar import NumberT, ProgressBarMixinBase + +logger = logging.getLogger(__name__) + +MAX_DATE = datetime.date.max +MAX_TIME = datetime.time.max +MAX_DATETIME = datetime.datetime.max + +Data = types.Dict[str, types.Any] +FormatString = typing.Optional[str] + +T = typing.TypeVar('T') def string_or_lambda(input_): - if isinstance(input_, six.basestring): + if isinstance(input_, str): + def render_input(progress, data, width): return input_ % data @@ -30,25 +42,73 @@ def render_input(progress, data, width): return input_ -def create_marker(marker): +def create_wrapper(wrapper): + """Convert a wrapper tuple or format string to a format string. + + >>> create_wrapper('') + + >>> print(create_wrapper('a{}b')) + a{}b + + >>> 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 `{}`', + ) + + return wrapper + + +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 + + @functools.wraps(function) + def wrap(*args: typing.Any, **kwargs: typing.Any): + return wrapper_.format(function(*args, **kwargs)) + + 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: + if ( + progress.max_value is not base.UnknownLength + and progress.max_value > 0 + ): length = int(progress.value / progress.max_value * width) - return (marker * length) + return marker * length else: return marker - if isinstance(marker, six.basestring): + if isinstance(marker, str): marker = converters.to_unicode(marker) - assert len(marker) == 1, 'Markers are required to be 1 char' - return _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 marker + return wrapper(marker, wrap) -class FormatWidgetMixin(object): - '''Mixin to format widgets using a formatstring +class FormatWidgetMixin(abc.ABC): + """Mixin to format widgets using a formatstring. Variables available: - max_value: The maximum value (can be None with iterators) @@ -61,153 +121,266 @@ class FormatWidgetMixin(object): - time_elapsed: Shortcut for HH:MM:SS time since the bar started including days - percentage: Percentage as a float - ''' - required_values = [] + """ - def __init__(self, format, **kwargs): + def __init__( + self, format: str, new_style: bool = False, **kwargs: typing.Any + ): + self.new_style = new_style self.format = format - def __call__(self, progress, data, format=None): - '''Formats the widget into a string''' + 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: - return (format or self.format) % data + if self.new_style: + return format_.format(**data) + else: + return format_ % data except (TypeError, KeyError): - print('Error while formatting %r' % self.format, file=sys.stderr) - pprint.pprint(data, stream=sys.stderr) + logger.exception( + 'Error while formatting %r with data: %r', + format_, + data, + ) raise -class WidthWidgetMixin(object): - '''Mixing to make sure widgets are only visible if the screen is within a +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.. - ''' + 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 - def __init__(self, min_width=None, max_width=None, **kwargs): + >>> 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 + """ + + def __init__(self, min_width=None, max_width=None, **kwargs: typing.Any): self.min_width = min_width self.max_width = max_width - def check_size(self, progress): - if self.min_width and self.min_width > progress.term_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 self.max_width and self.max_width < progress.term_width: + elif max_width and max_width < progress.term_width: # noqa: SIM103 return False else: return True -class WidgetBase(object): - __metaclass__ = abc.ABCMeta - '''The base class for all widgets +class TGradientColors(typing.TypedDict): + fg: types.Optional[terminal.OptionalColor | None] + bg: types.Optional[terminal.OptionalColor | None] + + +class TFixedColors(typing.TypedDict): + fg_none: types.Optional[terminal.Color | None] + bg_none: types.Optional[terminal.Color | None] + + +class WidgetBase(WidthWidgetMixin, metaclass=abc.ABCMeta): + """The base class for all widgets. 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. - The boolean INTERVAL informs the ProgressBar that it should be + The INTERVAL timedelta informs the ProgressBar that it should be updated more often because it is time sensitive. + The widgets are only visible if the screen is within a + specified size range so the progressbar fits on both large and small + screens. + 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. - ''' - def __init__(self, **kwargs): - pass + 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 + + """ + + copy = True @abc.abstractmethod - def __call__(self, progress, data): - '''Updates the widget. + 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 __init__( + self, + *args, + fixed_colors=None, + gradient_colors=None, + **kwargs, + ): + if fixed_colors is not None: + self._fixed_colors.update(fixed_colors) + + if gradient_colors is not None: + self._gradient_colors.update(gradient_colors) + + if self.uses_colors: + self._len = utils.len_color + + super().__init__(*args, **kwargs) -class AutoWidthWidgetBase(WidgetBase): - '''The base class for all variable width widgets. +class AutoWidthWidgetBase(WidgetBase, metaclass=abc.ABCMeta): + """The base class for all variable width widgets. 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. - ''' + """ @abc.abstractmethod - def __call__(self, progress, data, width): - '''Updates the widget providing the total width the widget must fill. + 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 - ''' + """ -class TimeSensitiveWidgetBase(WidgetBase): - '''The base class for all time sensitive widgets. +class TimeSensitiveWidgetBase(WidgetBase, metaclass=abc.ABCMeta): + """The base class for all time sensitive widgets. Some widgets like timers would become out of date unless updated at least every `INTERVAL` - ''' + """ + INTERVAL = datetime.timedelta(milliseconds=100) -class FormatLabel(FormatWidgetMixin, WidthWidgetMixin): - '''Displays a formatted label +class FormatLabel(FormatWidgetMixin, WidgetBase): + """Displays a formatted label. >>> label = FormatLabel('%(value)s', min_width=5, max_width=10) - >>> class Progress(object): + >>> class Progress: ... pass - - >>> Progress.term_width = 0 - >>> str(label(Progress, dict(value='test'))) - '' - - >>> Progress.term_width = 5 - >>> str(label(Progress, dict(value='test'))) - 'test' - - >>> Progress.term_width = 10 + >>> label = FormatLabel('{value} :: {value:^6}', new_style=True) >>> str(label(Progress, dict(value='test'))) - 'test' + 'test :: test ' - >>> Progress.term_width = 11 - >>> str(label(Progress, dict(value='test'))) - '' + """ - ''' + 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), + ) - mapping = { - '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, **kwargs): + def __init__(self, format: str, **kwargs: typing.Any): FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidthWidgetMixin.__init__(self, **kwargs) - - def __call__(self, progress, data, **kwargs): - if not self.check_size(progress): - return '' + WidgetBase.__init__(self, **kwargs) + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): for name, (key, transform) in self.mapping.items(): - try: + with contextlib.suppress(KeyError, ValueError, IndexError): if transform is None: data[name] = data[key] else: data[name] = transform(data[key]) - except: # pragma: no cover - pass - return FormatWidgetMixin.__call__(self, progress, data, **kwargs) + return FormatWidgetMixin.__call__(self, progress, data, format) class Timer(FormatLabel, TimeSensitiveWidgetBase): - '''WidgetBase which displays the elapsed seconds.''' + """WidgetBase which displays the elapsed seconds.""" + + def __init__( + self, format='Elapsed Time: %(elapsed)s', **kwargs: typing.Any + ): + if '%s' in format and '%(elapsed)s' not in format: + format = format.replace('%s', '%(elapsed)s') - def __init__(self, format='Elapsed Time: %(elapsed)s', **kwargs): FormatLabel.__init__(self, format=format, **kwargs) TimeSensitiveWidgetBase.__init__(self, **kwargs) @@ -215,18 +388,66 @@ def __init__(self, format='Elapsed Time: %(elapsed)s', **kwargs): format_time = staticmethod(utils.format_time) -class SamplesMixin(object): - def __init__(self, samples=10, key_prefix=None, **kwargs): - self.samples = samples - self.key_prefix = (self.__class__.__name__ or key_prefix) + '_' +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 - def get_sample_times(self, progress, data): - return progress.extra.setdefault(self.key_prefix + 'sample_times', []) + >>> progress.last_update_time += datetime.timedelta(seconds=1) + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True - def get_sample_values(self, progress, data): - return progress.extra.setdefault(self.key_prefix + 'sample_values', []) + >>> samples = SamplesMixin(samples=datetime.timedelta(seconds=1)) + >>> _, value = samples(progress, None) + >>> value + SliceableDeque([1, 1]) - def __call__(self, progress, data): + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True + """ + + 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) @@ -240,84 +461,123 @@ def __call__(self, progress, data): sample_times.append(progress.last_update_time) sample_values.append(progress.value) - if len(sample_times) > self.samples: + 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) - return sample_times, sample_values + 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 ETA(Timer): - '''WidgetBase which attempts to estimate the time of arrival.''' + """WidgetBase which attempts to estimate the time of arrival.""" def __init__( - self, - format_not_started='ETA: --:--:--', - format_finished='Time: %(elapsed)s', - format='ETA: %(eta)s', - format_zero='ETA: 0:00:00', - format_NA='ETA: N/A', - **kwargs): + self, + format_not_started='ETA: --:--:--', + format_finished='Time: %(elapsed)8s', + format='ETA: %(eta)8s', + format_zero='ETA: 00:00:00', + format_na='ETA: N/A', + **kwargs, + ): + if '%s' in format and '%(eta)s' not in format: + format = format.replace('%s', '%(eta)s') 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, data, value, elapsed): - '''Updates the widget to show the ETA or total time when finished.''' + 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 / max(value, 0.000000001) + per_item = elapsed.total_seconds() / max(value, 1e-6) remaining = progress.max_value - data['value'] - eta_seconds = remaining * per_item + return remaining * per_item else: - eta_seconds = 0 - - return eta_seconds - - def __call__(self, progress, data, value=None, elapsed=None): - '''Updates the widget to show the ETA or total time when finished.''' - + 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['total_seconds_elapsed'] + elapsed = data['time_elapsed'] - ETA_NA = False + eta_na = False try: data['eta_seconds'] = self._calculate_eta( - progress, data, value=value, elapsed=elapsed) + progress, + data, + value=value, + elapsed=elapsed, + ) except TypeError: data['eta_seconds'] = None - ETA_NA = True + eta_na = True + data['eta'] = None if data['eta_seconds']: - data['eta'] = utils.format_time(data['eta_seconds']) - else: - data['eta'] = None + with contextlib.suppress(ValueError, OverflowError, OSError): + data['eta'] = utils.format_time(data['eta_seconds']) if data['value'] == progress.min_value: - format = self.format_not_started + fmt = self.format_not_started elif progress.end_time: - format = self.format_finished + fmt = self.format_finished elif data['eta']: - format = self.format - elif ETA_NA: - format = self.format_NA + fmt = self.format + elif eta_na: + fmt = self.format_NA else: - format = self.format_zero + fmt = self.format_zero - return Timer.__call__(self, progress, data, format=format) + 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, data, value, elapsed): + """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: @@ -326,61 +586,134 @@ def _calculate_eta(self, progress, data, value, elapsed): 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='Estimated finish time: ----/--/-- --:--:--', - format_finished='Finished at: %(elapsed)s', - format='Estimated finish time: %(eta)s', - **kwargs): - Timer.__init__(self, **kwargs) - self.format_not_started = format_not_started - self.format_finished = format_finished - self.format = format + 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. + """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 __init__(self, **kwargs): + exponential_smoothing: bool + exponential_smoothing_factor: float + + def __init__( + self, + exponential_smoothing=True, + exponential_smoothing_factor=0.1, + **kwargs, + ): + self.exponential_smoothing = exponential_smoothing + self.exponential_smoothing_factor = exponential_smoothing_factor ETA.__init__(self, **kwargs) SamplesMixin.__init__(self, **kwargs) - def __call__(self, progress, data): - times, values = SamplesMixin.__call__(self, progress, data) - - if len(times) <= 1: - # No samples so just return the normal ETA calculation + 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 - else: - value = values[-1] - values[0] - elapsed = utils.timedelta_to_seconds(times[-1] - times[0]) return ETA.__call__(self, progress, data, value=value, elapsed=elapsed) -class DataSize(FormatWidgetMixin): - ''' +class SmoothingETA(ETA): + """ + WidgetBase which attempts to estimate the time of arrival using an + exponential moving average (EMA) of the speed. + + EMA applies more weight to recent data points and less to older ones, + and doesn't require storing all past values. This approach works well + with varying data points and smooths out fluctuations effectively. + """ + + smoothing_algorithm: algorithms.SmoothingAlgorithm + smoothing_parameters: dict[str, float] + + def __init__( + self, + smoothing_algorithm: type[ + algorithms.SmoothingAlgorithm + ] = algorithms.ExponentialMovingAverage, + smoothing_parameters: dict[str, float] | None = None, + **kwargs, + ): + self.smoothing_parameters = smoothing_parameters or {} + self.smoothing_algorithm = smoothing_algorithm( + **(self.smoothing_parameters or {}), + ) + ETA.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + if value is None: # pragma: no branch + value = data['value'] + + if elapsed is None: # pragma: no branch + elapsed = data['time_elapsed'] + + self.smoothing_algorithm.update(value, elapsed) + return ETA.__call__(self, progress, data, value=value, elapsed=elapsed) + + +class DataSize(FormatWidgetMixin, WidgetBase): + """ Widget for showing an amount of data transferred/processed. 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). - ''' + """ def __init__( - self, variable='value', - format='%(scaled)5.1f %(prefix)s%(unit)s', unit='B', - prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), - **kwargs): + self, + variable='value', + format='%(scaled)5.1f %(prefix)s%(unit)s', + unit='B', + prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), + **kwargs, + ): self.variable = variable self.unit = unit self.prefixes = prefixes FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, **kwargs) - def __call__(self, progress, data): + 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)) @@ -391,19 +724,22 @@ def __call__(self, progress, data): data['prefix'] = self.prefixes[power] data['unit'] = self.unit - return FormatWidgetMixin.__call__(self, progress, data) + return FormatWidgetMixin.__call__(self, progress, data, format) class FileTransferSpeed(FormatWidgetMixin, TimeSensitiveWidgetBase): - ''' - WidgetBase for showing the transfer speed (useful for file transfers). - ''' + """ + 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, + 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 @@ -414,13 +750,28 @@ def _speed(self, value, elapsed): speed = float(value) / elapsed return utils.scale_1024(speed, len(self.prefixes)) - def __call__(self, progress, data, value=None, total_seconds_elapsed=None): - '''Updates the widget with the current SI prefixed speed.''' - value = data['value'] or value - elapsed = data['total_seconds_elapsed'] or total_seconds_elapsed + 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'] - if value is not None and elapsed is not None \ - and elapsed > 2e-6 and value > 2e-6: # =~ 0 + 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 @@ -431,8 +782,12 @@ def __call__(self, progress, data, value=None, total_seconds_elapsed=None): scaled = 1 / scaled data['scaled'] = scaled data['prefix'] = self.prefixes[0] - return FormatWidgetMixin.__call__(self, progress, data, - self.inverse_format) + return FormatWidgetMixin.__call__( + self, + progress, + data, + self.inverse_format, + ) else: data['scaled'] = scaled data['prefix'] = self.prefixes[power] @@ -440,44 +795,79 @@ def __call__(self, progress, data, value=None, total_seconds_elapsed=None): class AdaptiveTransferSpeed(FileTransferSpeed, SamplesMixin): - '''WidgetBase for showing the transfer speed, based on the last X samples - ''' + """Widget for showing the transfer speed based on the last X samples.""" - def __init__(self, **kwargs): + def __init__(self, **kwargs: typing.Any): FileTransferSpeed.__init__(self, **kwargs) SamplesMixin.__init__(self, **kwargs) - def __call__(self, progress, data): - times, values = SamplesMixin.__call__(self, progress, data) - if len(times) <= 1: - # No samples so just return the normal transfer speed calculation - value = None - elapsed = None - else: - value = values[-1] - values[0] - elapsed = utils.timedelta_to_seconds(times[-1] - times[0]) - + 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(WidgetBase): - '''An animated marker for the progress bar which defaults to appear as if +class AnimatedMarker(TimeSensitiveWidgetBase): + """An animated marker for the progress bar which defaults to appear as if it were rotating. - ''' + """ - def __init__(self, markers='|/-\\', default=None, **kwargs): + 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, data, width=None): - '''Updates the widget to show the next marker or the first marker when - finished''' - + 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 - return self.markers[data['updates'] % len(self.markers)] + 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 = '' + + # 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) + + return fill + marker # type: ignore # Alias for backwards compatibility @@ -485,43 +875,93 @@ def __call__(self, progress, data, width=None): class Counter(FormatWidgetMixin, WidgetBase): - '''Displays the current count''' + """Displays the current count.""" - def __init__(self, format='%(value)d', **kwargs): + def __init__(self, format='%(value)d', **kwargs: typing.Any): 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.""" -class Percentage(FormatWidgetMixin, WidgetBase): - '''Displays the current percentage as a number with a percent sign.''' - - def __init__(self, format='%(percentage)3d%%', **kwargs): + def __init__( + self, format='%(percentage)3d%%', na='N/A%%', **kwargs: typing.Any + ): + self.na = na FormatWidgetMixin.__init__(self, format=format, **kwargs) WidgetBase.__init__(self, format=format, **kwargs) - def __call__(self, progress, data, format=None): + def get_format( + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, + ): # If percentage is not available, display N/A% - if 'percentage' in data and not data['percentage']: - return FormatWidgetMixin.__call__(self, progress, data, - format='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) - return FormatWidgetMixin.__call__(self, progress, data) +class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase): + """Returns progress as a count of the total (e.g.: "5 of 47").""" -class SimpleProgress(FormatWidgetMixin, 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, max_width=None, **kwargs): - self.max_width = dict(default=max_width) + def __init__(self, format=DEFAULT_FORMAT, **kwargs: typing.Any): FormatWidgetMixin.__init__(self, format=format, **kwargs) WidgetBase.__init__(self, format=format, **kwargs) - - def __call__(self, progress, data, format=None): + 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.get('max_value') + data['max_value_s'] = data['max_value'] else: data['max_value_s'] = 'N/A' @@ -531,12 +971,19 @@ def __call__(self, progress, data, format=None): else: data['value_s'] = 0 - formatted = FormatWidgetMixin.__call__(self, progress, data, - format=format) + 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 = self.max_width.get(key, self.max_width['default']) + 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: @@ -544,26 +991,42 @@ def __call__(self, progress, data, format=None): continue temporary_data['value'] = value - width = len(FormatWidgetMixin.__call__( - self, progress, temporary_data, format=format)) - if width: # pragma: no branch + 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[key] = max_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 formatted + return self._apply_colors(formatted, data) class Bar(AutoWidthWidgetBase): - '''A progress bar which stretches to fill the line.''' + """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, **kwargs): - '''Creates a customizable progress bar. + 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 @@ -572,9 +1035,8 @@ def __init__(self, marker='#', left='|', right='|', fill=' ', 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 = create_marker(marker) + """ + 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) @@ -582,58 +1044,89 @@ def __init__(self, marker='#', left='|', right='|', fill=' ', AutoWidthWidgetBase.__init__(self, **kwargs) - def __call__(self, progress, data, width): - '''Updates the progress bar and its subcomponents''' - + 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 -= len(left) + len(right) + 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: marker = marker.ljust(width, fill) else: marker = marker.rjust(width, fill) + if color: + marker = self._apply_colors(marker, data) + return left + marker + right class ReverseBar(Bar): - '''A bar which has a marker that goes from right to left''' + """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. + 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 - ''' - Bar.__init__(self, marker=marker, left=left, right=right, fill=fill, - fill_left=fill_left, **kwargs) + """ + 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.''' + """A bar which has a marker which bounces from side to side.""" INTERVAL = datetime.timedelta(milliseconds=100) - def __call__(self, progress, data, width): - '''Updates the progress bar and its subcomponents''' - + 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 -= len(left) + len(right) + 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()) + data['total_seconds_elapsed'] / self.INTERVAL.total_seconds(), + ) a = value % width b = width - a - 1 @@ -648,59 +1141,363 @@ def __call__(self, progress, data, width): return left + marker + right -class FormatCustomText(FormatWidgetMixin, WidthWidgetMixin): - mapping = {} +class FormatCustomText(FormatWidgetMixin, WidgetBase): + mapping: types.Dict[str, types.Any] = dict() # noqa: RUF012 + copy = False - def __init__(self, format, mapping=mapping, **kwargs): + def __init__( + self, + format: str, + mapping: types.Optional[types.Dict[str, types.Any]] = None, + **kwargs, + ): self.format = format - self.mapping = mapping + self.mapping = mapping or self.mapping FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidthWidgetMixin.__init__(self, **kwargs) + WidgetBase.__init__(self, **kwargs) - def update_mapping(self, **mapping): + def update_mapping(self, **mapping: types.Dict[str, types.Any]): self.mapping.update(mapping) - def __call__(self, progress, data): - return FormatWidgetMixin.__call__(self, progress, self.mapping, - self.format) + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + return FormatWidgetMixin.__call__( + self, + progress, + self.mapping, + format or self.format, + ) -class DynamicMessage(FormatWidgetMixin, WidgetBase): - '''Displays a custom variable.''' +class VariableMixin: + """Mixin to display a custom user variable.""" - def __init__(self, name): - '''Creates a DynamicMessage associated with the given name.''' + def __init__(self, name, **kwargs: typing.Any): if not isinstance(name, str): - raise TypeError('DynamicMessage(): argument must be a string') + raise TypeError('Variable(): argument must be a string') if len(name.split()) > 1: - raise ValueError( - 'DynamicMessage(): argument must be single word') - + raise ValueError('Variable(): argument must be single word') self.name = name - def __call__(self, progress, data): - val = data['dynamic_messages'][self.name] - if val: - return self.name + ': ' + '{:6.3g}'.format(val) + +class MultiRangeBar(Bar, VariableMixin): + """ + A bar with multiple sub-ranges, each represented by a different symbol. + + The various ranges are represented on a user-defined variable, formatted as + + .. code-block:: python + + [['Symbol1', amount1], ['Symbol2', amount2], ...] + """ + + def __init__(self, name, markers, **kwargs: typing.Any): + 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 + """ + + 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: - return self.name + ': ' + 6 * '-' + 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: typing.Any): + 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: typing.Any + ): + 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.''' + """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): + 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, data): + 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) + return FormatWidgetMixin.__call__(self, progress, data, format=format) def current_datetime(self): now = datetime.datetime.now() @@ -712,3 +1509,119 @@ def current_datetime(self): 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. + """ + + 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 left + marker + right diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..1a484a94 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,222 @@ +[build-system] +build-backend = 'setuptools.build_meta' +requires = ['setuptools', 'setuptools-scm'] + +[project] +name = 'progressbar2' +description = 'A Python Progressbar library to provide visual (yet text based) progress to long running operations.' +dynamic = ['version'] +authors = [{ name = 'Rick van Hattem (Wolph)', email = 'wolph@wol.ph' }] +license = { text = 'BSD-3-Clause' } +readme = 'README.rst' +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', +] +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.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + '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', + 'Topic :: Software Development :: Libraries :: Python Modules', + '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', + 'Typing :: Typed', +] + +requires-python = '>3.8' +dependencies = ['python-utils >= 3.8.1'] + +[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/' + +[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"', +] + +[dependency-groups] +dev = ['progressbar2[docs,tests]'] + +[tool.black] +line-length = 79 +skip-string-normalization = true + +[tool.codespell] +skip = '*/htmlcov,./docs/_build,*.asc' +ignore-words-list = 'datas,numbert' + +[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.:', + 'typing.Protocol', +] + + +[tool.mypy] +packages = ['progressbar', 'tests'] +exclude = [ + '^docs$', + '^tests/original_examples.py$', + '^examples.py$', +] + + +[tool.pyright] +include = ['progressbar'] +exclude = ['examples', '.tox'] +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 + + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.dynamic] +version = { attr = 'progressbar.__about__.__version__' } + +[tool.setuptools.packages.find] +exclude = ['docs*', 'tests*'] diff --git a/pytest.ini b/pytest.ini index d0dde185..08a11301 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,32 +2,27 @@ python_files = progressbar/*.py tests/*.py - examples.py addopts = --cov progressbar - --cov-report term-missing - --cov-report html + --cov-report=html + --cov-report=term-missing + --cov-report=xml + --cov-config=./pyproject.toml --no-cov-on-fail --doctest-modules - --pep8 - --flakes - -pep8ignore = - *.py W391 - docs/*.py ALL - progressbar/six.py ALL - -flakes-ignore = - docs/*.py ALL - progressbar/six.py ALL norecursedirs = - .svn - _build - tmp* - docs + .* + _* build dist - .ropeproject - .tox + 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..e27f4f84 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,114 @@ +# We keep the ruff configuration separate so it can easily be shared across +# all projects + +target-version = 'py39' + +#src = ['progressbar'] +exclude = [ + '.venv', + '.tox', + # Ignore local test files/directories/old-stuff + 'test.py', + '*_old.py', +] + +line-length = 79 + +[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 + 'FA100', # Missing `from __future__ import annotations`, but uses `typing.Optional` + 'COM812', # Missing trailing comma in a list + 'ISC001', # String concatenation with implicit str conversion + 'SIM108', # Ternary operators are not always more readable + 'RUF100', # Unused noqa directives. Due to multiple Python versions, we need to keep them +] + +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 06303f99..00000000 --- a/setup.cfg +++ /dev/null @@ -1,38 +0,0 @@ -[aliases] -test=pytest - -[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 - -[flake8] -ignore = W391 - -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100644 index c99faf70..00000000 --- a/setup.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import os -import sys - - -try: - from setuptools import setup, find_packages -except ImportError: - from distutils.core import setup, find_packages - -# To prevent importing about and thereby breaking the coverage info we use this -# exec hack -about = {} -with open("progressbar/__about__.py") as fp: - exec(fp.read(), about) - - -install_reqs = [] -tests_reqs = [] - -if sys.version_info < (2, 7): - tests_reqs += ['unittest2'] - - -def parse_requirements(filename): - '''Read the requirements from the filename, supports includes''' - requirements = [] - - if os.path.isfile(filename): - with open(filename) as fh: - for line in fh: - line = line.strip() - if line.startswith('-r'): - requirements += parse_requirements( - os.path.join(os.path.dirname(filename), - line.split(' ', 1)[-1])) - elif line and not line.startswith('#'): - requirements.append(line) - - return requirements - - -if sys.argv[-1] == 'info': - for k, v in about.items(): - print('%s: %s' % (k, v)) - sys.exit() - -if os.path.isfile('README.rst'): - with open('README.rst') as fh: - readme = fh.read() -else: - readme = \ - 'See http://pypi.python.org/pypi/%(__package_name__)s/' % about - - -if __name__ == '__main__': - setup( - name=about['__package_name__'], - version=about['__version__'], - author=about['__author__'], - author_email=about['__email__'], - description=about['__description__'], - url=about['__url__'], - license=about['__license__'], - keywords=about['__title__'], - packages=find_packages(exclude=['docs']), - long_description=readme, - include_package_data=True, - install_requires=[ - 'python-utils>=2.1.0', - ], - tests_require=tests_reqs, - setup_requires=['setuptools', 'pytest-runner>=2.8'], - zip_safe=False, - extras_require={ - 'docs': [ - 'changelog', - 'sphinx>=1.5.0', - ], - 'tests': [ - 'flake8', - 'pytest>=2.8', - 'pytest-cache', - 'pytest-cov', - 'pytest-flakes', - 'pytest-pep8', - 'sphinx', - ], - }, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Natural Language :: English', - "Programming Language :: Python :: 2", - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: Implementation :: PyPy', - ], - ) diff --git a/tests/conftest.py b/tests/conftest.py index 19b8eef3..787e643e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,16 @@ -import pytest -import progressbar +from __future__ import annotations + import logging +import time +import timeit +from datetime import datetime +import freezegun +import pytest -LOG_LEVELS = { +import progressbar + +LOG_LEVELS: dict[str, int] = { '0': logging.ERROR, '1': logging.WARNING, '2': logging.INFO, @@ -11,14 +18,32 @@ } -def pytest_configure(config): +def pytest_configure(config) -> None: logging.basicConfig( - level=LOG_LEVELS.get(config.option.verbose, logging.DEBUG)) + level=LOG_LEVELS.get(config.option.verbose, logging.DEBUG), + ) @pytest.fixture(autouse=True) -def small_interval(monkeypatch): +def small_interval(monkeypatch) -> None: # Remove the update limit for tests by default monkeypatch.setattr( - progressbar.ProgressBar, '_MINIMUM_UPDATE_INTERVAL', 0.000001) + 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..30d6c0f6 --- /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 = f'Example {int(fn.__name__[7:]):d}' + 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/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..4a368af4 --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,412 @@ +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 Color, 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(rgb) + assert color('test') + + color_no_name = Color( + rgb=color.rgb, + hls=color.hls, + name=None, + xterm=color.xterm, + ) + # Test without name + assert str(color_no_name) != str(color) + + +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 index 4f7522be..b0a272e4 100644 --- a/tests/test_custom_widgets.py +++ b/tests/test_custom_widgets.py @@ -1,3 +1,7 @@ +import time + +import pytest + import progressbar @@ -6,13 +10,13 @@ class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): def update(self, pbar): if 45 < pbar.percentage() < 80: - return 'Bigger Now ' + progressbar.FileTransferSpeed.update(self, - pbar) + 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(): +def test_crazy_file_transfer_speed_widget() -> None: widgets = [ # CrazyFileTransferSpeed(), ' <<<', @@ -28,16 +32,64 @@ def test_crazy_file_transfer_speed_widget(): p.start() for i in range(0, 200, 5): # do something + time.sleep(0.1) p.update(i + 1) p.finish() -def test_dynamic_message_widget(): - widgets = [' [', progressbar.Timer(), '] ', progressbar.Bar(), ' (', - progressbar.ETA(), ') ', progressbar.DynamicMessage('loss')] +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) + 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): - p.update(i + 1, loss=.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 index 039cffbb..43071632 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,21 +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): +@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 index 495d6b7b..7e5cfcce 100644 --- a/tests/test_data_transfer_bar.py +++ b/tests/test_data_transfer_bar.py @@ -2,14 +2,14 @@ from progressbar import DataTransferBar -def test_known_length(): +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(): +def test_unknown_length() -> None: dtb = DataTransferBar().start(max_value=progressbar.UnknownLength) for i in range(50): dtb.update(i) 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 index de6bf09a..f326e384 100644 --- a/tests/test_empty.py +++ b/tests/test_empty.py @@ -1,12 +1,11 @@ import progressbar -def test_empty_list(): +def test_empty_list() -> None: for x in progressbar.ProgressBar()([]): print(x) -def test_empty_iterator(): +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 index 4cc1f10c..e7c69e3e 100644 --- a/tests/test_end.py +++ b/tests/test_end.py @@ -1,52 +1,56 @@ import pytest + import progressbar @pytest.fixture(autouse=True) -def large_interval(monkeypatch): +def large_interval(monkeypatch) -> None: # Remove the update limit for tests by default monkeypatch.setattr( - progressbar.ProgressBar, '_MINIMUM_UPDATE_INTERVAL', 0.1) + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + 0.1, + ) -def test_end(): +def test_end() -> None: m = 24514315 p = progressbar.ProgressBar( widgets=[progressbar.Percentage(), progressbar.Bar()], - max_value=m + max_value=m, ) for x in range(0, m, 8192): p.update(x) data = p.data() - assert data['percentage'] < 100. + assert data['percentage'] < 100.0 p.finish() data = p.data() - assert data['percentage'] >= 100. + assert data['percentage'] >= 100.0 assert p.value == m -def test_end_100(monkeypatch): - progressbar.ProgressBar._MINIMUM_UPDATE_INTERVAL = 0.1 +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(0, 102): + for x in range(102): p.update(x) data = p.data() import pprint + pprint.pprint(data) - assert data['percentage'] < 100. + assert data['percentage'] < 100.0 p.finish() data = p.data() - assert data['percentage'] >= 100. - + assert data['percentage'] >= 100.0 diff --git a/tests/test_failure.py b/tests/test_failure.py index 13b7bdbd..5953284b 100644 --- a/tests/test_failure.py +++ b/tests/test_failure.py @@ -1,8 +1,13 @@ +import logging +import time + import pytest + import progressbar -def test_missing_format_values(): +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')], @@ -10,36 +15,38 @@ def test_missing_format_values(): p.update(5) -def test_max_smaller_than_min(): +def test_max_smaller_than_min() -> None: with pytest.raises(ValueError): progressbar.ProgressBar(min_value=10, max_value=5) -def test_no_max_value(): - '''Looping up to 5 without max_value? No problem''' +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(): - '''Looping up to 5 when max_value is 10? No problem''' +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(): - '''negative max_value, shouldn't work''' +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(): - '''max_value of zero, it could happen''' +def test_zero_max_value() -> None: + """max_value of zero, it could happen""" p = progressbar.ProgressBar(max_value=0) p.update(0) @@ -47,8 +54,8 @@ def test_zero_max_value(): p.update(1) -def test_one_max_value(): - '''max_value of one, another corner case''' +def test_one_max_value() -> None: + """max_value of one, another corner case""" p = progressbar.ProgressBar(max_value=1) p.update(0) @@ -58,54 +65,76 @@ def test_one_max_value(): p.update(2) -def test_changing_max_value(): - '''Changing max_value? No problem''' +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: - pass + for _i in p: + time.sleep(1) -def test_backwards(): - '''progressbar going backwards''' +def test_backwards() -> None: + """progressbar going backwards""" p = progressbar.ProgressBar(max_value=1) p.update(1) p.update(0) -def test_incorrect_max_value(): - '''Looping up to 10 when max_value is 5? This is madness!''' +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(): +def test_deprecated_maxval() -> None: with pytest.warns(DeprecationWarning): progressbar.ProgressBar(maxval=5) -def test_deprecated_poll(): +def test_deprecated_poll() -> None: with pytest.warns(DeprecationWarning): progressbar.ProgressBar(poll=5) -def test_unexpected_update_keyword_arg(): +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_dynamic_message_not_str(): +def test_variable_not_str() -> None: with pytest.raises(TypeError): - progressbar.DynamicMessage(1) + progressbar.Variable(1) -def test_dynamic_message_too_many_strs(): +def test_variable_too_many_strs() -> None: with pytest.raises(ValueError): - progressbar.DynamicMessage('too long') + 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 index 7f4317eb..edade1c3 100644 --- a/tests/test_flush.py +++ b/tests/test_flush.py @@ -1,12 +1,12 @@ -from __future__ import print_function - import time + import progressbar -def test_flush(): - '''Left justify using the terminal width''' +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) @@ -15,4 +15,3 @@ def test_flush(): if i > 5: time.sleep(0.1) print('post-updates', p.updates) - diff --git a/tests/test_iterators.py b/tests/test_iterators.py index 188809a3..d4474e7e 100644 --- a/tests/test_iterators.py +++ b/tests/test_iterators.py @@ -1,57 +1,61 @@ import time + import pytest + import progressbar -def test_list(): - '''Progressbar can guess max_value automatically.''' +def test_list() -> None: + """Progressbar can guess max_value automatically.""" p = progressbar.ProgressBar() - for i in p(range(10)): + for _i in p(range(10)): time.sleep(0.001) -def test_iterator_with_max_value(): - '''Progressbar can't guess max_value.''' +def test_iterator_with_max_value() -> None: + """Progressbar can't guess max_value.""" p = progressbar.ProgressBar(max_value=10) - for i in p((i for i in range(10))): + for _i in p(iter(range(10))): time.sleep(0.001) -def test_iterator_without_max_value_error(): - '''Progressbar can't guess max_value.''' +def test_iterator_without_max_value_error() -> None: + """Progressbar can't guess max_value.""" p = progressbar.ProgressBar() - for i in p((i for i in range(10))): + for _i in p(iter(range(10))): time.sleep(0.001) assert p.max_value is progressbar.UnknownLength -def test_iterator_without_max_value(): - '''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((i for i in range(10))): +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(): - '''Progressbar can't guess max_value.''' +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((i for i in range(20))): + for _i in p(iter(range(20))): time.sleep(0.001) -def test_adding_value(): +def test_adding_value() -> None: p = progressbar.ProgressBar(max_value=10) p.start() p.update(5) - p += 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 index 0786f8c6..2e7ad72f 100644 --- a/tests/test_large_values.py +++ b/tests/test_large_values.py @@ -1,9 +1,17 @@ import time + import progressbar -def test_large_max_value(): +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 index c07002f7..b0725afe 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,7 +1,7 @@ from progressbar import __about__ -def test_about(): +def test_about() -> None: assert __about__.__title__ assert __about__.__package_name__ assert __about__.__author__ diff --git a/tests/test_monitor_progress.py b/tests/test_monitor_progress.py new file mode 100644 index 00000000..90e802a8 --- /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 = [ + fr'[/\\|\-]\s+\|\s*#\s*\| {i:d} Elapsed Time: \d:00:{i:02d}' + 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 6a163164..23270a46 100644 --- a/tests/test_progressbar.py +++ b/tests/test_progressbar.py @@ -1,16 +1,79 @@ +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 -def test_examples_nullbar(monkeypatch): +# 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 - import progressbar monkeypatch.setattr(progressbar, 'ProgressBar', progressbar.NullBar) assert progressbar.ProgressBar._MINIMUM_UPDATE_INTERVAL < 0.0001 - import examples - examples.non_interactive_sleep_factor = 10000 - for example in examples.examples: - example() + 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 index d7a338b3..4f53639e 100644 --- a/tests/test_speed.py +++ b/tests/test_speed.py @@ -1,27 +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): +@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 - + 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 index 42f8240a..d14845d8 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -1,12 +1,16 @@ import io +import os import sys + import pytest + import progressbar +from progressbar import terminal -def test_nowrap(): +def test_nowrap() -> None: # Make sure we definitely unwrap - for i in range(5): + for _i in range(5): progressbar.streams.unwrap(stderr=True, stdout=True) stdout = sys.stdout @@ -23,13 +27,13 @@ def test_nowrap(): assert stderr == sys.stderr # Make sure we definitely unwrap - for i in range(5): + for _i in range(5): progressbar.streams.unwrap(stderr=True, stdout=True) -def test_wrap(): +def test_wrap() -> None: # Make sure we definitely unwrap - for i in range(5): + for _i in range(5): progressbar.streams.unwrap(stderr=True, stdout=True) stdout = sys.stdout @@ -50,24 +54,23 @@ def test_wrap(): assert stderr == sys.stderr # Make sure we definitely unwrap - for i in range(5): + for _i in range(5): progressbar.streams.unwrap(stderr=True, stdout=True) -def test_excepthook(): +def test_excepthook() -> None: progressbar.streams.wrap(stderr=True, stdout=True) try: - raise RuntimeError() - except: - progressbar.streams.excepthook(sys.exc_type, sys.exc_value, - sys.exc_traceback) + 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(): +def test_fd_as_io_stream() -> None: stream = io.StringIO() with progressbar.ProgressBar(fd=stream) as pb: for i in range(101): @@ -75,8 +78,73 @@ def test_fd_as_io_stream(): 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__]) -def test_fd_as_standard_streams(stream): +@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 index f55f3df9..3980e5f8 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -1,36 +1,42 @@ -from __future__ import print_function - +import signal import sys import time -import signal -import progressbar from datetime import timedelta +import progressbar +from progressbar import terminal + -def test_left_justify(): - '''Left justify using the terminal width''' +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) + 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(): - '''Right justify using the terminal width''' +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) + 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): - '''Right justify using the terminal width''' +def test_auto_width(monkeypatch) -> None: + """Right justify using the terminal width""" def ioctl(*args): return '\xbf\x00\xeb\x00\x00\x00\x00\x00' @@ -40,12 +46,17 @@ def fake_signal(signal, func): 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) + 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): @@ -54,36 +65,41 @@ def fake_signal(signal, func): pass # Skip on Windows -def test_fill_right(): - '''Right justify using the terminal width''' +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) + 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(): - '''Right justify using the terminal width''' +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) + 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): - '''Simply bounce within the terminal width''' +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) + term_width=20, + ) assert p.term_width is not None for i in range(30): @@ -92,16 +108,19 @@ def test_no_fill(monkeypatch): p.start_time = p.start_time - timedelta(seconds=i) -def test_stdout_redirection(): - p = progressbar.ProgressBar(fd=sys.stdout, max_value=10, - redirect_stdout=True) +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(): +def test_double_stdout_redirection() -> None: p = progressbar.ProgressBar(max_value=10, redirect_stdout=True) p2 = progressbar.ProgressBar(max_value=10, redirect_stdout=True) @@ -111,7 +130,7 @@ def test_double_stdout_redirection(): p2.update(i) -def test_stderr_redirection(): +def test_stderr_redirection() -> None: p = progressbar.ProgressBar(max_value=10, redirect_stderr=True) for i in range(10): @@ -119,9 +138,12 @@ def test_stderr_redirection(): p.update(i) -def test_stdout_stderr_redirection(): - p = progressbar.ProgressBar(max_value=10, redirect_stdout=True, - redirect_stderr=True) +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): @@ -133,7 +155,7 @@ def test_stdout_stderr_redirection(): p.finish() -def test_resize(monkeypatch): +def test_resize(monkeypatch) -> None: def ioctl(*args): return '\xbf\x00\xeb\x00\x00\x00\x00\x00' @@ -142,6 +164,7 @@ def fake_signal(signal, func): try: import fcntl + monkeypatch.setattr(fcntl, 'ioctl', ioctl) monkeypatch.setattr(signal, 'signal', fake_signal) @@ -156,3 +179,10 @@ def fake_signal(signal, func): 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 index 172353db..ee19ab95 100644 --- a/tests/test_timed.py +++ b/tests/test_timed.py @@ -1,15 +1,19 @@ -import time import datetime +import time + import progressbar -def test_timer(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' +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 = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) p.start() p.update() @@ -20,13 +24,17 @@ def test_timer(): p.finish() -def test_eta(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' +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 = progressbar.ProgressBar( + min_value=0, + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) p.start() time.sleep(0.001) @@ -43,8 +51,8 @@ def test_eta(): p.update(2) -def test_adaptive_eta(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' +def test_adaptive_eta() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" widgets = [ progressbar.AdaptiveETA(), ] @@ -57,19 +65,22 @@ def test_adaptive_eta(): ) p.start() - for i in range(20): + for _i in range(20): p.update(1) time.sleep(0.001) p.finish() -def test_adaptive_transfer_speed(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' +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 = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) p.start() p.update(1) @@ -78,15 +89,80 @@ def test_adaptive_transfer_speed(): p.finish() -def test_non_changing_eta(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' +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 = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) p.start() p.update(1) @@ -97,15 +173,15 @@ def test_non_changing_eta(): def test_eta_not_available(): """ - When ETA is not available (data coming from a generator), - ETAs should not raise exceptions. + When ETA is not available (data coming from a generator), + ETAs should not raise exceptions. """ + def gen(): - for x in range(200): - yield x + yield from range(200) widgets = [progressbar.AdaptiveETA(), progressbar.ETA()] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar(gen()): + for _i in bar(gen()): pass diff --git a/tests/test_timer.py b/tests/test_timer.py index b9e63286..083e1b18 100644 --- a/tests/test_timer.py +++ b/tests/test_timer.py @@ -1,29 +1,46 @@ -import progressbar from datetime import timedelta +import pytest -def test_poll_interval(): - # Test int, float and timedelta intervals - bar = progressbar.ProgressBar(poll_interval=1) - assert bar.poll_interval.seconds == 1 - assert bar.poll_interval.microseconds == 0 - - bar = progressbar.ProgressBar(poll_interval=.001) - assert bar.poll_interval.seconds == 0 - assert bar.poll_interval.microseconds < 1001 - - bar = progressbar.ProgressBar(poll_interval=timedelta(seconds=1)) - assert bar.poll_interval.seconds == 1 - assert bar.poll_interval.microseconds == 0 - - bar = progressbar.ProgressBar(poll_interval=timedelta(microseconds=1000)) - assert bar.poll_interval.seconds == 0 - assert bar.poll_interval.microseconds < 1001 +import progressbar -def test_intervals(): +@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) - bar._MINIMUM_UPDATE_INTERVAL = 1 # Initially there should be no last_update_time assert bar.last_update_time is None @@ -41,4 +58,3 @@ def test_intervals(): 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 index 3b8e4aec..3babbddd 100644 --- a/tests/test_unicode.py +++ b/tests/test_unicode.py @@ -1,29 +1,33 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations import time + import pytest -import progressbar from python_utils import converters +import progressbar -@pytest.mark.parametrize('name,markers', [ - ('line arrows', u'←↖↑↗→↘↓↙'), - ('block arrows', u'◢◣◤◥'), - ('wheels', u'◐◓◑◒'), -]) + +@pytest.mark.parametrize( + 'name,markers', + [ + ('line arrows', '←↖↑↗→↘↓↙'), + ('block arrows', '◢◣◤◥'), + ('wheels', '◐◓◑◒'), + ], +) @pytest.mark.parametrize('as_unicode', [True, False]) -def test_markers(name, markers, as_unicode): +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 = [ - '%s: ' % name.capitalize(), + f'{name.capitalize()}: ', progressbar.AnimatedMarker(markers=markers), ] bar = progressbar.ProgressBar(widgets=widgets) bar._MINIMUM_UPDATE_INTERVAL = 1e-12 - for i in bar((i for i in range(24))): + 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 index 83659aae..65a54779 100644 --- a/tests/test_unknown_length.py +++ b/tests/test_unknown_length.py @@ -1,27 +1,30 @@ import progressbar -from progressbar import ProgressBar, UnknownLength -def test_unknown_length(): - pb = ProgressBar(widgets=[progressbar.AnimatedMarker()], - max_value=UnknownLength) - assert pb.max_value is UnknownLength +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(): +def test_unknown_length_default_widgets() -> None: # The default widgets picked should work without a known max_value - pb = ProgressBar(max_value=UnknownLength).start() + pb = progressbar.ProgressBar(max_value=progressbar.UnknownLength).start() for i in range(60): pb.update(i) pb.finish() -def test_unknown_length_at_start(): +def test_unknown_length_at_start() -> None: # The default widgets should be picked after we call .start() - pb = ProgressBar().start(max_value=UnknownLength) + pb = progressbar.ProgressBar().start(max_value=progressbar.UnknownLength) for i in range(60): pb.update(i) pb.finish() - pb2 = ProgressBar().start(max_value=UnknownLength) - assert any([isinstance(w, progressbar.Bar) for w in pb2.widgets]) + 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 index 461c2f60..5ee3bacd 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,8 +1,27 @@ +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(): + +def test_widgets_small_values() -> None: widgets = [ 'Test: ', progressbar.Percentage(), @@ -18,12 +37,13 @@ def test_widgets_small_values(): p = progressbar.ProgressBar(widgets=widgets, max_value=10).start() p.update(0) for i in range(10): - time.sleep(0.001) + time.sleep(1) p.update(i + 1) p.finish() -def test_widgets_large_values(): +@pytest.mark.parametrize('max_value', [10**6, 10**8]) +def test_widgets_large_values(max_value) -> None: widgets = [ 'Test: ', progressbar.Percentage(), @@ -36,24 +56,25 @@ def test_widgets_large_values(): ' ', progressbar.FileTransferSpeed(), ] - p = progressbar.ProgressBar(widgets=widgets, max_value=10 ** 6).start() - for i in range(0, 10 ** 6, 10 ** 4): - time.sleep(0.001) + 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(): - widgets = [] - for mapping in progressbar.FormatLabel.mapping: - widgets.append(progressbar.FormatLabel('%%(%s)r' % mapping)) - +def test_format_widget() -> None: + widgets = [ + progressbar.FormatLabel(f'%({mapping})r') + for mapping in progressbar.FormatLabel.mapping + ] p = progressbar.ProgressBar(widgets=widgets) - for i in p(range(10)): - time.sleep(0.001) + for _ in p(range(10)): + time.sleep(1) -def test_all_widgets_small_values(): +@pytest.mark.parametrize('max_value', [None, 10]) +def test_all_widgets_small_values(max_value) -> None: widgets = [ progressbar.Timer(), progressbar.ETA(), @@ -65,7 +86,7 @@ def test_all_widgets_small_values(): progressbar.AnimatedMarker(), progressbar.Counter(), progressbar.Percentage(), - progressbar.FormatLabel('%(value)d/%(max_value)d'), + progressbar.FormatLabel('%(value)d'), progressbar.SimpleProgress(), progressbar.Bar(), progressbar.ReverseBar(), @@ -74,14 +95,15 @@ def test_all_widgets_small_values(): progressbar.CurrentTime(microseconds=False), progressbar.CurrentTime(microseconds=True), ] - p = progressbar.ProgressBar(widgets=widgets, max_value=10) + p = progressbar.ProgressBar(widgets=widgets, max_value=max_value) for i in range(10): - time.sleep(0.001) + time.sleep(1) p.update(i + 1) p.finish() -def test_all_widgets_large_values(): +@pytest.mark.parametrize('max_value', [10**6, 10**7]) +def test_all_widgets_large_values(max_value) -> None: widgets = [ progressbar.Timer(), progressbar.ETA(), @@ -98,10 +120,93 @@ def test_all_widgets_large_values(): 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=10 ** 6) - for i in range(0, 10 ** 6, 10 ** 4): - time.sleep(0.001) - p.update(i + 1) - p.finish() + 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 != '' +def test_format_label() -> None: + bar = progressbar.ProgressBar(widgets=[ + progressbar.FormatLabel('Processed: %(value)d') + ], max_value=10) + for _ in bar(range(10)): + pass 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 index 1fd2a1f6..3d2253f5 100644 --- a/tests/test_with.py +++ b/tests/test_with.py @@ -1,20 +1,19 @@ import progressbar -def test_with(): +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(): +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(): +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 ede56219..c6812323 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,44 @@ [tox] -envlist = py27, pypy, flake8, py33, py34, py35, docs +envlist = + py38 + py39 + py310 + py311 + py312 + docs + black + ruff +; mypy +; codespell skip_missing_interpreters = True [testenv] -basepython = - py27: python2.7 - py33: python3.3 - py34: python3.4 - py35: python3.5 - pypy: pypy +deps = + -r{toxinidir}/tests/requirements.txt + pyright +commands = + pyright + py.test --basetemp="{envtmpdir}" --confcutdir=.. {posargs} +skip_install = true -deps = -r{toxinidir}/tests/requirements.txt -commands = python -m pytest {posargs} +[testenv:mypy] +changedir = +basepython = python3 +deps = mypy +commands = mypy {toxinidir}/progressbar -[testenv:flake8] -basepython = python2.7 -deps = flake8 -commands = flake8 --ignore=W391 {toxinidir}/progressbar {toxinidir}/tests {toxinidir}/examples.py +[testenv:black] +basepython = python3 +deps = black +commands = black --skip-string-normalization --line-length 79 {toxinidir}/progressbar [testenv:docs] -basepython = python2.7 +changedir = +basepython = python3 deps = -r{toxinidir}/docs/requirements.txt +allowlist_externals = + rm + mkdir whitelist_externals = rm cd @@ -28,8 +46,20 @@ whitelist_externals = commands = rm -f docs/modules.rst mkdir -p docs/_static - sphinx-apidoc -e -o docs/ progressbar + sphinx-apidoc -e -o docs/ progressbar */os_specific/* rm -f docs/modules.rst - rm -f docs/progressbar.rst - sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html {posargs} + sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html {posargs} + +[testenv:ruff] +commands = + ruff check + ruff format +deps = ruff +skip_install = true +[testenv:codespell] +changedir = {toxinidir} +commands = codespell . +deps = codespell +skip_install = true +command = codespell diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..7aa77446 --- /dev/null +++ b/uv.lock @@ -0,0 +1,858 @@ +version = 1 +requires-python = ">3.8" +resolution-markers = [ + "python_full_version < '3.9'", + "python_full_version == '3.9.*'", + "python_full_version == '3.10.*'", + "python_full_version >= '3.11'", +] + +[[package]] +name = "alabaster" +version = "0.7.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/71/a8ee96d1fd95ca04a0d2e2d9c4081dac4c2d2b12f7ddb899c8cb9bfd1532/alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2", size = 11454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/88/c7083fc61120ab661c5d0b82cb77079fc1429d3f913a456c1c82cf4658f7/alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3", size = 13857 }, +] + +[[package]] +name = "attrs" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, +] + +[[package]] +name = "babel" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/8b/825cc84cf13a28bfbcba7c416ec22bf85a9584971be15b21dd8300c65b7f/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", size = 196363 }, + { url = "https://files.pythonhosted.org/packages/23/81/d7eef6a99e42c77f444fdd7bc894b0ceca6c3a95c51239e74a722039521c/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", size = 125639 }, + { url = "https://files.pythonhosted.org/packages/21/67/b4564d81f48042f520c948abac7079356e94b30cb8ffb22e747532cf469d/charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", size = 120451 }, + { url = "https://files.pythonhosted.org/packages/c2/72/12a7f0943dd71fb5b4e7b55c41327ac0a1663046a868ee4d0d8e9c369b85/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", size = 140041 }, + { url = "https://files.pythonhosted.org/packages/67/56/fa28c2c3e31217c4c52158537a2cf5d98a6c1e89d31faf476c89391cd16b/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", size = 150333 }, + { url = "https://files.pythonhosted.org/packages/f9/d2/466a9be1f32d89eb1554cf84073a5ed9262047acee1ab39cbaefc19635d2/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", size = 142921 }, + { url = "https://files.pythonhosted.org/packages/f8/01/344ec40cf5d85c1da3c1f57566c59e0c9b56bcc5566c08804a95a6cc8257/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", size = 144785 }, + { url = "https://files.pythonhosted.org/packages/73/8b/2102692cb6d7e9f03b9a33a710e0164cadfce312872e3efc7cfe22ed26b4/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", size = 146631 }, + { url = "https://files.pythonhosted.org/packages/d8/96/cc2c1b5d994119ce9f088a9a0c3ebd489d360a2eb058e2c8049f27092847/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", size = 140867 }, + { url = "https://files.pythonhosted.org/packages/c9/27/cde291783715b8ec30a61c810d0120411844bc4c23b50189b81188b273db/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", size = 149273 }, + { url = "https://files.pythonhosted.org/packages/3a/a4/8633b0fc1a2d1834d5393dafecce4a1cc56727bfd82b4dc18fc92f0d3cc3/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", size = 152437 }, + { url = "https://files.pythonhosted.org/packages/64/ea/69af161062166b5975ccbb0961fd2384853190c70786f288684490913bf5/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", size = 150087 }, + { url = "https://files.pythonhosted.org/packages/3b/fd/e60a9d9fd967f4ad5a92810138192f825d77b4fa2a557990fd575a47695b/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", size = 145142 }, + { url = "https://files.pythonhosted.org/packages/6d/02/8cb0988a1e49ac9ce2eed1e07b77ff118f2923e9ebd0ede41ba85f2dcb04/charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", size = 94701 }, + { url = "https://files.pythonhosted.org/packages/d6/20/f1d4670a8a723c46be695dff449d86d6092916f9e99c53051954ee33a1bc/charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", size = 102191 }, + { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, + { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, + { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, + { url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243 }, + { url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676 }, + { url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289 }, + { url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585 }, + { url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408 }, + { url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076 }, + { url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874 }, + { url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871 }, + { url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546 }, + { url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048 }, + { url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389 }, + { url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752 }, + { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, + { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, + { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, + { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, + { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, + { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, + { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, + { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, + { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, + { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, + { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, + { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, + { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, + { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, + { url = "https://files.pythonhosted.org/packages/86/f4/ccab93e631e7293cca82f9f7ba39783c967f823a0000df2d8dd743cad74f/charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", size = 193961 }, + { url = "https://files.pythonhosted.org/packages/94/d4/2b21cb277bac9605026d2d91a4a8872bc82199ed11072d035dc674c27223/charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", size = 124507 }, + { url = "https://files.pythonhosted.org/packages/9a/e0/a7c1fcdff20d9c667342e0391cfeb33ab01468d7d276b2c7914b371667cc/charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", size = 119298 }, + { url = "https://files.pythonhosted.org/packages/70/de/1538bb2f84ac9940f7fa39945a5dd1d22b295a89c98240b262fc4b9fcfe0/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", size = 139328 }, + { url = "https://files.pythonhosted.org/packages/e9/ca/288bb1a6bc2b74fb3990bdc515012b47c4bc5925c8304fc915d03f94b027/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", size = 149368 }, + { url = "https://files.pythonhosted.org/packages/aa/75/58374fdaaf8406f373e508dab3486a31091f760f99f832d3951ee93313e8/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", size = 141944 }, + { url = "https://files.pythonhosted.org/packages/32/c8/0bc558f7260db6ffca991ed7166494a7da4fda5983ee0b0bfc8ed2ac6ff9/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", size = 143326 }, + { url = "https://files.pythonhosted.org/packages/0e/dd/7f6fec09a1686446cee713f38cf7d5e0669e0bcc8288c8e2924e998cf87d/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", size = 146171 }, + { url = "https://files.pythonhosted.org/packages/4c/a8/440f1926d6d8740c34d3ca388fbd718191ec97d3d457a0677eb3aa718fce/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", size = 139711 }, + { url = "https://files.pythonhosted.org/packages/e9/7f/4b71e350a3377ddd70b980bea1e2cc0983faf45ba43032b24b2578c14314/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", size = 148348 }, + { url = "https://files.pythonhosted.org/packages/1e/70/17b1b9202531a33ed7ef41885f0d2575ae42a1e330c67fddda5d99ad1208/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", size = 151290 }, + { url = "https://files.pythonhosted.org/packages/44/30/574b5b5933d77ecb015550aafe1c7d14a8cd41e7e6c4dcea5ae9e8d496c3/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", size = 149114 }, + { url = "https://files.pythonhosted.org/packages/0b/11/ca7786f7e13708687443082af20d8341c02e01024275a28bc75032c5ce5d/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", size = 143856 }, + { url = "https://files.pythonhosted.org/packages/f9/c2/1727c1438256c71ed32753b23ec2e6fe7b6dff66a598f6566cfe8139305e/charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", size = 94333 }, + { url = "https://files.pythonhosted.org/packages/09/c8/0e17270496a05839f8b500c1166e3261d1226e39b698a735805ec206967b/charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", size = 101454 }, + { url = "https://files.pythonhosted.org/packages/54/2f/28659eee7f5d003e0f5a3b572765bf76d6e0fe6601ab1f1b1dd4cba7e4f1/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", size = 196326 }, + { url = "https://files.pythonhosted.org/packages/d1/18/92869d5c0057baa973a3ee2af71573be7b084b3c3d428fe6463ce71167f8/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", size = 125614 }, + { url = "https://files.pythonhosted.org/packages/d6/27/327904c5a54a7796bb9f36810ec4173d2df5d88b401d2b95ef53111d214e/charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", size = 120450 }, + { url = "https://files.pythonhosted.org/packages/a4/23/65af317914a0308495133b2d654cf67b11bbd6ca16637c4e8a38f80a5a69/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", size = 140135 }, + { url = "https://files.pythonhosted.org/packages/f2/41/6190102ad521a8aa888519bb014a74251ac4586cde9b38e790901684f9ab/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", size = 150413 }, + { url = "https://files.pythonhosted.org/packages/7b/ab/f47b0159a69eab9bd915591106859f49670c75f9a19082505ff16f50efc0/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", size = 142992 }, + { url = "https://files.pythonhosted.org/packages/28/89/60f51ad71f63aaaa7e51a2a2ad37919985a341a1d267070f212cdf6c2d22/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", size = 144871 }, + { url = "https://files.pythonhosted.org/packages/0c/48/0050550275fea585a6e24460b42465020b53375017d8596c96be57bfabca/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", size = 146756 }, + { url = "https://files.pythonhosted.org/packages/dc/b5/47f8ee91455946f745e6c9ddbb0f8f50314d2416dd922b213e7d5551ad09/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", size = 141034 }, + { url = "https://files.pythonhosted.org/packages/84/79/5c731059ebab43e80bf61fa51666b9b18167974b82004f18c76378ed31a3/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", size = 149434 }, + { url = "https://files.pythonhosted.org/packages/ca/f3/0719cd09fc4dc42066f239cb3c48ced17fc3316afca3e2a30a4756fe49ab/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", size = 152443 }, + { url = "https://files.pythonhosted.org/packages/f7/0e/c6357297f1157c8e8227ff337e93fd0a90e498e3d6ab96b2782204ecae48/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", size = 150294 }, + { url = "https://files.pythonhosted.org/packages/54/9a/acfa96dc4ea8c928040b15822b59d0863d6e1757fba8bd7de3dc4f761c13/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", size = 145314 }, + { url = "https://files.pythonhosted.org/packages/73/1c/b10a63032eaebb8d7bcb8544f12f063f41f5f463778ac61da15d9985e8b6/charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", size = 94724 }, + { url = "https://files.pythonhosted.org/packages/c5/77/3a78bf28bfaa0863f9cfef278dbeadf55efe064eafff8c7c424ae3c4c1bf/charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", size = 102159 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, + { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, + { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, + { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, + { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, + { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, + { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, + { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, + { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, + { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, + { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, + { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, + { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, + { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, + { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, + { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, + { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, + { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, + { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, + { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, + { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, + { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, + { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, + { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, + { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, + { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, + { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, + { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, + { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, + { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, + { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, + { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, + { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, + { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, + { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, + { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, + { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, + { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, + { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, + { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, + { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, + { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, + { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, + { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, + { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, + { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, + { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, + { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, + { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, + { url = "https://files.pythonhosted.org/packages/81/d0/d9e3d554e38beea5a2e22178ddb16587dbcbe9a1ef3211f55733924bf7fa/coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", size = 206674 }, + { url = "https://files.pythonhosted.org/packages/38/ea/cab2dc248d9f45b2b7f9f1f596a4d75a435cb364437c61b51d2eb33ceb0e/coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", size = 207101 }, + { url = "https://files.pythonhosted.org/packages/ca/6f/f82f9a500c7c5722368978a5390c418d2a4d083ef955309a8748ecaa8920/coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", size = 236554 }, + { url = "https://files.pythonhosted.org/packages/a6/94/d3055aa33d4e7e733d8fa309d9adf147b4b06a82c1346366fc15a2b1d5fa/coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", size = 234440 }, + { url = "https://files.pythonhosted.org/packages/e4/6e/885bcd787d9dd674de4a7d8ec83faf729534c63d05d51d45d4fa168f7102/coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", size = 235889 }, + { url = "https://files.pythonhosted.org/packages/f4/63/df50120a7744492710854860783d6819ff23e482dee15462c9a833cc428a/coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", size = 235142 }, + { url = "https://files.pythonhosted.org/packages/3a/5d/9d0acfcded2b3e9ce1c7923ca52ccc00c78a74e112fc2aee661125b7843b/coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", size = 233805 }, + { url = "https://files.pythonhosted.org/packages/c4/56/50abf070cb3cd9b1dd32f2c88f083aab561ecbffbcd783275cb51c17f11d/coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", size = 234655 }, + { url = "https://files.pythonhosted.org/packages/25/ee/b4c246048b8485f85a2426ef4abab88e48c6e80c74e964bea5cd4cd4b115/coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", size = 209296 }, + { url = "https://files.pythonhosted.org/packages/5c/1c/96cf86b70b69ea2b12924cdf7cabb8ad10e6130eab8d767a1099fbd2a44f/coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", size = 210137 }, + { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, + { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, + { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, + { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, + { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, + { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, + { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, + { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, + { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, + { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, + { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "dill" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/43/86fe3f9e130c4137b0f1b50784dd70a5087b911fe07fa81e53e0c4c47fea/dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c", size = 187000 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/d1/e73b6ad76f0b1fb7f23c35c6d95dbc506a9c8804f43dda8cb5b0fa6331fd/dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", size = 119418 }, +] + +[[package]] +name = "docutils" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "filelock" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, +] + +[[package]] +name = "flake8" +version = "5.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/00/9808c62b2d529cefc69ce4e4a1ea42c0f855effa55817b7327ec5b75e60a/flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db", size = 145862 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/a0/b881b63a17a59d9d07f5c0cc91a29182c8e8a9aa2bde5b3b2b16519c02f4/flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248", size = 61897 }, +] + +[[package]] +name = "freezegun" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, +] + +[[package]] +name = "markupsafe" +version = "2.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/54/ad5eb37bf9d51800010a74e4665425831a9db4e7c4e0fde4352e391e808e/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", size = 18206 }, + { url = "https://files.pythonhosted.org/packages/6a/4a/a4d49415e600bacae038c67f9fecc1d5433b9d3c71a4de6f33537b89654c/MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", size = 14079 }, + { url = "https://files.pythonhosted.org/packages/0a/7b/85681ae3c33c385b10ac0f8dd025c30af83c78cec1c37a6aa3b55e67f5ec/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", size = 26620 }, + { url = "https://files.pythonhosted.org/packages/7c/52/2b1b570f6b8b803cef5ac28fdf78c0da318916c7d2fe9402a84d591b394c/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", size = 25818 }, + { url = "https://files.pythonhosted.org/packages/29/fe/a36ba8c7ca55621620b2d7c585313efd10729e63ef81e4e61f52330da781/MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", size = 25493 }, + { url = "https://files.pythonhosted.org/packages/60/ae/9c60231cdfda003434e8bd27282b1f4e197ad5a710c14bee8bea8a9ca4f0/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", size = 30630 }, + { url = "https://files.pythonhosted.org/packages/65/dc/1510be4d179869f5dafe071aecb3f1f41b45d37c02329dfba01ff59e5ac5/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", size = 29745 }, + { url = "https://files.pythonhosted.org/packages/30/39/8d845dd7d0b0613d86e0ef89549bfb5f61ed781f59af45fc96496e897f3a/MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", size = 30021 }, + { url = "https://files.pythonhosted.org/packages/c7/5c/356a6f62e4f3c5fbf2602b4771376af22a3b16efa74eb8716fb4e328e01e/MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", size = 16659 }, + { url = "https://files.pythonhosted.org/packages/69/48/acbf292615c65f0604a0c6fc402ce6d8c991276e16c80c46a8f758fbd30c/MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", size = 17213 }, + { url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219 }, + { url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098 }, + { url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014 }, + { url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220 }, + { url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756 }, + { url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988 }, + { url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718 }, + { url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317 }, + { url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670 }, + { url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224 }, + { url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215 }, + { url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069 }, + { url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452 }, + { url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462 }, + { url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869 }, + { url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906 }, + { url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296 }, + { url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038 }, + { url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572 }, + { url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127 }, + { url = "https://files.pythonhosted.org/packages/f8/ff/2c942a82c35a49df5de3a630ce0a8456ac2969691b230e530ac12314364c/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", size = 18192 }, + { url = "https://files.pythonhosted.org/packages/4f/14/6f294b9c4f969d0c801a4615e221c1e084722ea6114ab2114189c5b8cbe0/MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", size = 14072 }, + { url = "https://files.pythonhosted.org/packages/81/d4/fd74714ed30a1dedd0b82427c02fa4deec64f173831ec716da11c51a50aa/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", size = 26928 }, + { url = "https://files.pythonhosted.org/packages/c7/bd/50319665ce81bb10e90d1cf76f9e1aa269ea6f7fa30ab4521f14d122a3df/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", size = 26106 }, + { url = "https://files.pythonhosted.org/packages/4c/6f/f2b0f675635b05f6afd5ea03c094557bdb8622fa8e673387444fe8d8e787/MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68", size = 25781 }, + { url = "https://files.pythonhosted.org/packages/51/e0/393467cf899b34a9d3678e78961c2c8cdf49fb902a959ba54ece01273fb1/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", size = 30518 }, + { url = "https://files.pythonhosted.org/packages/f6/02/5437e2ad33047290dafced9df741d9efc3e716b75583bbd73a9984f1b6f7/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", size = 29669 }, + { url = "https://files.pythonhosted.org/packages/0e/7d/968284145ffd9d726183ed6237c77938c021abacde4e073020f920e060b2/MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", size = 29933 }, + { url = "https://files.pythonhosted.org/packages/bf/f3/ecb00fc8ab02b7beae8699f34db9357ae49d9f21d4d3de6f305f34fa949e/MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", size = 16656 }, + { url = "https://files.pythonhosted.org/packages/92/21/357205f03514a49b293e214ac39de01fadd0970a6e05e4bf1ddd0ffd0881/MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", size = 17206 }, + { url = "https://files.pythonhosted.org/packages/0f/31/780bb297db036ba7b7bbede5e1d7f1e14d704ad4beb3ce53fb495d22bc62/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", size = 18193 }, + { url = "https://files.pythonhosted.org/packages/6c/77/d77701bbef72892affe060cdacb7a2ed7fd68dae3b477a8642f15ad3b132/MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", size = 14073 }, + { url = "https://files.pythonhosted.org/packages/d9/a7/1e558b4f78454c8a3a0199292d96159eb4d091f983bc35ef258314fe7269/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", size = 26486 }, + { url = "https://files.pythonhosted.org/packages/5f/5a/360da85076688755ea0cceb92472923086993e86b5613bbae9fbc14136b0/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", size = 25685 }, + { url = "https://files.pythonhosted.org/packages/6a/18/ae5a258e3401f9b8312f92b028c54d7026a97ec3ab20bfaddbdfa7d8cce8/MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", size = 25338 }, + { url = "https://files.pythonhosted.org/packages/0b/cc/48206bd61c5b9d0129f4d75243b156929b04c94c09041321456fd06a876d/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", size = 30439 }, + { url = "https://files.pythonhosted.org/packages/d1/06/a41c112ab9ffdeeb5f77bc3e331fdadf97fa65e52e44ba31880f4e7f983c/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", size = 29531 }, + { url = "https://files.pythonhosted.org/packages/02/8c/ab9a463301a50dab04d5472e998acbd4080597abc048166ded5c7aa768c8/MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", size = 29823 }, + { url = "https://files.pythonhosted.org/packages/bc/29/9bc18da763496b055d8e98ce476c8e718dcfd78157e17f555ce6dd7d0895/MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", size = 16658 }, + { url = "https://files.pythonhosted.org/packages/f6/f8/4da07de16f10551ca1f640c92b5f316f9394088b183c6a57183df6de5ae4/MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", size = 17211 }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, +] + +[[package]] +name = "mypy" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/8c/206de95a27722b5b5a8c85ba3100467bd86299d92a4f71c6b9aa448bfa2f/mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", size = 11020731 }, + { url = "https://files.pythonhosted.org/packages/ab/bb/b31695a29eea76b1569fd28b4ab141a1adc9842edde080d1e8e1776862c7/mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", size = 10184276 }, + { url = "https://files.pythonhosted.org/packages/a5/2d/4a23849729bb27934a0e079c9c1aad912167d875c7b070382a408d459651/mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", size = 12587706 }, + { url = "https://files.pythonhosted.org/packages/5c/c3/d318e38ada50255e22e23353a469c791379825240e71b0ad03e76ca07ae6/mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", size = 13105586 }, + { url = "https://files.pythonhosted.org/packages/4a/25/3918bc64952370c3dbdbd8c82c363804678127815febd2925b7273d9482c/mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", size = 9632318 }, + { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, + { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, + { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, + { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, + { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/5e/2a/13e9ad339131c0fba5c70584f639005a47088f5eed77081a3d00479df0ca/mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a", size = 10955147 }, + { url = "https://files.pythonhosted.org/packages/94/39/02929067dc16b72d78109195cfed349ac4ec85f3d52517ac62b9a5263685/mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb", size = 10138373 }, + { url = "https://files.pythonhosted.org/packages/4a/cc/066709bb01734e3dbbd1375749f8789bf9693f8b842344fc0cf52109694f/mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b", size = 12543621 }, + { url = "https://files.pythonhosted.org/packages/f5/a2/124df839025348c7b9877d0ce134832a9249968e3ab36bb826bab0e9a1cf/mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74", size = 13050348 }, + { url = "https://files.pythonhosted.org/packages/45/86/cc94b1e7f7e756a63043cf425c24fb7470013ee1c032180282db75b1b335/mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6", size = 9615311 }, + { url = "https://files.pythonhosted.org/packages/5f/d4/b33ddd40dad230efb317898a2d1c267c04edba73bc5086bf77edeb410fb2/mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", size = 11013906 }, + { url = "https://files.pythonhosted.org/packages/f4/e6/f414bca465b44d01cd5f4a82761e15044bedd1bf8025c5af3cc64518fac5/mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732", size = 10180657 }, + { url = "https://files.pythonhosted.org/packages/38/e9/fc3865e417722f98d58409770be01afb961e2c1f99930659ff4ae7ca8b7e/mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc", size = 12586394 }, + { url = "https://files.pythonhosted.org/packages/2e/35/f4d8b6d2cb0b3dad63e96caf159419dda023f45a358c6c9ac582ccaee354/mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d", size = 13103591 }, + { url = "https://files.pythonhosted.org/packages/22/1d/80594aef135f921dd52e142fa0acd19df197690bd0cde42cea7b88cf5aa2/mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24", size = 9634690 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "progressbar2" +version = "4.5.0" +source = { editable = "." } +dependencies = [ + { name = "python-utils" }, +] + +[package.optional-dependencies] +docs = [ + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, +] +tests = [ + { name = "dill" }, + { name = "flake8" }, + { name = "freezegun" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mypy" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sphinx" }, +] + +[package.dev-dependencies] +dev = [ + { name = "progressbar2", extra = ["docs", "tests"] }, +] + +[package.metadata] +requires-dist = [ + { name = "dill", marker = "extra == 'tests'", specifier = ">=0.3.6" }, + { name = "flake8", marker = "extra == 'tests'", specifier = ">=3.7.7" }, + { name = "freezegun", marker = "extra == 'tests'", specifier = ">=0.3.11" }, + { name = "pytest", marker = "extra == 'tests'", specifier = ">=4.6.9" }, + { name = "pytest-cov", marker = "extra == 'tests'", specifier = ">=2.6.1" }, + { name = "pytest-mypy", marker = "extra == 'tests'" }, + { name = "python-utils", specifier = ">=3.8.1" }, + { name = "pywin32", marker = "sys_platform == 'win32' and extra == 'tests'" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=1.8.5" }, + { name = "sphinx", marker = "extra == 'tests'", specifier = ">=1.8.5" }, + { name = "sphinx-autodoc-typehints", marker = "extra == 'docs'", specifier = ">=1.6.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "progressbar2", extras = ["docs", "tests"] }] + +[[package]] +name = "pycodestyle" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/83/5bcaedba1f47200f0665ceb07bcb00e2be123192742ee0edfb66b600e5fd/pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785", size = 102127 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/e4/fc77f1039c34b3612c4867b69cbb2b8a4e569720b1f19b0637002ee03aff/pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b", size = 41493 }, +] + +[[package]] +name = "pyflakes" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/92/f0cb5381f752e89a598dd2850941e7f570ac3cb8ea4a344854de486db152/pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3", size = 66388 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/13/63178f59f74e53acc2165aee4b002619a3cfa7eeaeac989a9eb41edf364e/pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2", size = 66116 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, +] + +[[package]] +name = "pytest-mypy" +version = "0.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "filelock" }, + { name = "mypy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/3a/318c91140f242cafff64ddac97d6999640bc3da9afbf37253475c2208e79/pytest-mypy-0.10.3.tar.gz", hash = "sha256:f8458f642323f13a2ca3e2e61509f7767966b527b4d8adccd5032c3e7b4fd3db", size = 14020 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/02/36a48da6d4168db8fb596c040680665bee89a7bced22ba1eee75920059c4/pytest_mypy-0.10.3-py3-none-any.whl", hash = "sha256:7638d0d3906848fc1810cb2f5cc7fceb4cc5c98524aafcac58f28620e3102053", size = 7110 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-utils" +version = "3.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/0c/587d2274217c13e9d1ba091560e9161ae94dd04053b390d70ef612b0af81/python-utils-3.8.2.tar.gz", hash = "sha256:c5d161e4ca58ce3f8c540f035e018850b261a41e7cb98f6ccf8e1deb7174a1f1", size = 30431 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/45/98431ba6d17b99468bd3f4c53fdeefff402167f006a06773905296f6d489/python_utils-3.8.2-py2.py3-none-any.whl", hash = "sha256:ad0ccdbd6f856d015cace07f74828b9840b5c4072d9e868a7f6a14fd195555a8", size = 27047 }, +] + +[[package]] +name = "pytz" +version = "2024.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, +] + +[[package]] +name = "pywin32" +version = "308" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/a6/3e9f2c474895c1bb61b11fa9640be00067b5c5b363c501ee9c3fa53aec01/pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e", size = 5927028 }, + { url = "https://files.pythonhosted.org/packages/d9/b4/84e2463422f869b4b718f79eb7530a4c1693e96b8a4e5e968de38be4d2ba/pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e", size = 6558484 }, + { url = "https://files.pythonhosted.org/packages/9f/8f/fb84ab789713f7c6feacaa08dad3ec8105b88ade8d1c4f0f0dfcaaa017d6/pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c", size = 7971454 }, + { url = "https://files.pythonhosted.org/packages/eb/e2/02652007469263fe1466e98439831d65d4ca80ea1a2df29abecedf7e47b7/pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a", size = 5928156 }, + { url = "https://files.pythonhosted.org/packages/48/ef/f4fb45e2196bc7ffe09cad0542d9aff66b0e33f6c0954b43e49c33cad7bd/pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b", size = 6559559 }, + { url = "https://files.pythonhosted.org/packages/79/ef/68bb6aa865c5c9b11a35771329e95917b5559845bd75b65549407f9fc6b4/pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6", size = 7972495 }, + { url = "https://files.pythonhosted.org/packages/00/7c/d00d6bdd96de4344e06c4afbf218bc86b54436a94c01c71a8701f613aa56/pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897", size = 5939729 }, + { url = "https://files.pythonhosted.org/packages/21/27/0c8811fbc3ca188f93b5354e7c286eb91f80a53afa4e11007ef661afa746/pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", size = 6543015 }, + { url = "https://files.pythonhosted.org/packages/9d/0f/d40f8373608caed2255781a3ad9a51d03a594a1248cd632d6a298daca693/pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091", size = 7976033 }, + { url = "https://files.pythonhosted.org/packages/a9/a4/aa562d8935e3df5e49c161b427a3a2efad2ed4e9cf81c3de636f1fdddfd0/pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed", size = 5938579 }, + { url = "https://files.pythonhosted.org/packages/c7/50/b0efb8bb66210da67a53ab95fd7a98826a97ee21f1d22949863e6d588b22/pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4", size = 6542056 }, + { url = "https://files.pythonhosted.org/packages/26/df/2b63e3e4f2df0224f8aaf6d131f54fe4e8c96400eb9df563e2aae2e1a1f9/pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", size = 7974986 }, + { url = "https://files.pythonhosted.org/packages/f3/0d/2c464011689e11ff5d64a32337f37de463a0cb058e45de5ea4027b56601a/pywin32-308-cp38-cp38-win32.whl", hash = "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0", size = 5998793 }, + { url = "https://files.pythonhosted.org/packages/b7/e8/729b049e3c5c5449049d6036edf7a24a6ba785a9a1d5f617b638a9b444eb/pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de", size = 6647446 }, + { url = "https://files.pythonhosted.org/packages/a8/41/ead05a7657ffdbb1edabb954ab80825c4f87a3de0285d59f8290457f9016/pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341", size = 5991824 }, + { url = "https://files.pythonhosted.org/packages/e4/cd/0838c9a6063bff2e9bac2388ae36524c26c50288b5d7b6aebb6cdf8d375d/pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920", size = 6640327 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/7b/af302bebf22c749c56c9c3e8ae13190b5b5db37a33d9068652e8f73b7089/snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", size = 86699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002 }, +] + +[[package]] +name = "sphinx" +version = "7.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/01/688bdf9282241dca09fe6e3a1110eda399fa9b10d0672db609e37c2e7a39/sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f", size = 6828258 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/17/325cf6a257d84751a48ae90752b3d8fe0be8f9535b6253add61c49d0d9bc/sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe", size = 3169543 }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/f0/b750f1ea593df9ba152e99929807530604d06fae887e5a38ae1e0a31358a/sphinx_autodoc_typehints-2.0.1.tar.gz", hash = "sha256:60ed1e3b2c970acc0aa6e877be42d48029a9faec7378a17838716cacd8c10b12", size = 38816 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/5baffb0ef1b8fd72d0a5a3ab531e82c5e810df3530c8f61857c69026b7ac/sphinx_autodoc_typehints-2.0.1-py3-none-any.whl", hash = "sha256:f73ae89b43a799e587e39266672c1075b2ef783aeb382d3ebed77c38a3fc0149", size = 19533 }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/df/45e827f4d7e7fcc84e853bcef1d836effd762d63ccb86f43ede4e98b478c/sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e", size = 24766 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/c1/5e2cafbd03105ce50d8500f9b4e8a6e8d02e22d0475b574c3b3e9451a15f/sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228", size = 120601 }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/33/dc28393f16385f722c893cb55539c641c9aaec8d1bc1c15b69ce0ac2dbb3/sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4", size = 17398 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/09/5de5ed43a521387f18bdf5f5af31d099605c992fd25372b2b9b825ce48ee/sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", size = 84690 }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/47/64cff68ea3aa450c373301e5bebfbb9fce0a3e70aca245fcadd4af06cd75/sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff", size = 27967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/ee/a1f5e39046cbb5f8bc8fba87d1ddf1c6643fbc9194e58d26e606de4b9074/sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903", size = 99833 }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071 }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/8e/c4846e59f38a5f2b4a0e3b27af38f2fcf904d4bfd82095bf92de0b114ebd/sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", size = 21658 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/14/05f9206cf4e9cfca1afb5fd224c7cd434dcc3a433d6d9e4e0264d29c6cdb/sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6", size = 90609 }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/72/835d6fadb9e5d02304cf39b18f93d227cd93abd3c41ebf58e6853eeb1455/sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952", size = 21019 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/77/5464ec50dd0f1c1037e3c93249b040c8fc8078fdda97530eeb02424b6eea/sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", size = 94021 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, +] + +[[package]] +name = "zipp" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/bf/5c0000c44ebc80123ecbdddba1f5dcd94a5ada602a9c225d84b5aaa55e86/zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29", size = 24199 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/8b/5ba542fa83c90e09eac972fc9baca7a88e7e7ca4b221a89251954019308b/zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", size = 9200 }, +]