diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 943c74da..00000000 --- a/.coveragerc +++ /dev/null @@ -1,15 +0,0 @@ -[run] -branch = True -source = - progressbar - tests -omit = - */mock/* - */nose/* -[paths] -source = - progressbar -[report] -exclude_lines = - pragma: no cover - @abc.abstractmethod diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..5c0820ff --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: WoLpH diff --git a/.github/ci-reporter.yml b/.github/ci-reporter.yml new file mode 100644 index 00000000..11114586 --- /dev/null +++ b/.github/ci-reporter.yml @@ -0,0 +1,8 @@ +# Set to false to create a new comment instead of updating the app's first one +updateComment: true + +# Use a custom string, or set to false to disable +before: "✨ Good work on this PR so far! ✨ Unfortunately, the [ build]() is failing as of . Here's the output:" + +# Use a custom string, or set to false to disable +after: "I'm sure you can fix it! If you need help, don't hesitate to ask a maintainer of the project!" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..44ccfb21 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: "CodeQL" + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + schedule: + - cron: "24 21 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python, javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + if: ${{ matrix.language == 'python' || matrix.language == 'javascript' }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..19244660 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,58 @@ +name: tox + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + name: tox (${{ matrix.tox-env }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - python-version: '3.10' + tox-env: py310 + - python-version: '3.11' + tox-env: py311 + - python-version: '3.12' + tox-env: py312 + - python-version: '3.13' + tox-env: py313 + - python-version: '3.14' + tox-env: py314 + # Python 3.15 is a pre-release and currently unsupported by + # typing_extensions (no released version survives + # `from typing_extensions import *` on 3.15 because + # no_type_check_decorator is still listed in __all__ after its + # removal from typing), which breaks the python_utils import. + # Failures are advisory until upstream catches up. + - python-version: '3.15-dev' + tox-env: py315 + experimental: true + - python-version: '3.14' + tox-env: docs + - python-version: '3.14' + tox-env: black + - python-version: '3.14' + tox-env: ruff + + steps: + - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Install dependencies + run: | + python -m pip install --upgrade pip tox + - name: Test with tox + # Step-level continue-on-error keeps the job green for + # experimental (pre-release Python) environments while still + # showing the failing step in the logs + continue-on-error: ${{ matrix.experimental || false }} + run: tox -e ${{ matrix.tox-env }} 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 b839bcbf..00000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: python -python: - - "2.6" - - "2.7" - - "3.3" - - "3.4" - - "pypy" - - "pypy3" - -# command to install dependencies -install: - - pip install . - - pip install -r requirements_test.txt - -before_script: flake8 --ignore=W391 progressbar tests - -# command to run tests -script: - - python setup.py test - -after_success: - - coveralls - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..66808cd5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ + +# Memory Context + +# [python-progressbar] recent context, 2026-05-11 12:05am GMT+2 + +Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖️decision 🚨security_alert 🔐security_note +Format: ID TIME TYPE TITLE +Fetch details: get_observations([IDs]) | Search: mem-search skill + +Stats: 1 obs (414t read) | 19,980t work | 98% savings + +### May 11, 2026 +2388 12:04a ✅ CI/tox config updated to test Python 3.10–3.15 + +Access 20k tokens of past work via get_observations([IDs]) or mem-search skill. + \ No newline at end of file diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 00000000..ebb9d853 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,11 @@ +========= +Changelog +========= + +For the most recent changes to the Python Progressbar please look at the Git +releases or the commit log: + + - https://github.com/WoLpH/python-progressbar/releases + - https://github.com/WoLpH/python-progressbar/commits/develop + +Hint: click on the `...` button to see the change message. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..6e24af25 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,124 @@ +============ +Contributing +============ + +Contributions are welcome, and they are greatly appreciated! Every +little bit helps, and credit will always be given. + +You can contribute in many ways: + +Types of Contributions +---------------------- + +Report Bugs +~~~~~~~~~~~ + +Report bugs at https://github.com/WoLpH/python-progressbar/issues. + +If you are reporting a bug, please include: + +* Your operating system name and version. +* Any details about your local setup that might be helpful in troubleshooting. +* Detailed steps to reproduce the bug. + +Fix Bugs +~~~~~~~~ + +Look through the GitHub issues for bugs. Anything tagged with "bug" +is open to whoever wants to implement it. + +Implement Features +~~~~~~~~~~~~~~~~~~ + +Look through the GitHub issues for features. Anything tagged with "feature" +is open to whoever wants to implement it. + +Write Documentation +~~~~~~~~~~~~~~~~~~~ + +Python Progressbar could always use more documentation, whether as part of the +official Python Progressbar docs, in docstrings, or even on the web in blog posts, +articles, and such. + +Submit Feedback +~~~~~~~~~~~~~~~ + +The best way to send feedback is to file an issue at https://github.com/WoLpH/python-progressbar/issues. + +If you are proposing a feature: + +* Explain in detail how it would work. +* Keep the scope as narrow as possible, to make it easier to implement. +* Remember that this is a volunteer-driven project, and that contributions + are welcome :) + +Get Started! +------------ + +Ready to contribute? Here's how to set up `python-progressbar` for local development. + +1. Fork the `python-progressbar` repo on GitHub. +2. Clone your fork locally:: + + $ git clone --branch develop git@github.com:your_name_here/python-progressbar.git + +3. Install your local copy into a virtualenv. Assuming you have `uv` installed, this is how you set up your fork for local development:: + + $ cd progressbar/ + $ uv sync + +4. Create a branch for local development with `git-flow-avh`_:: + + $ git-flow feature start name-of-your-bugfix-or-feature + + Or without git-flow: + + $ git checkout -b feature/name-of-your-bugfix-or-feature + + Now you can make your changes locally. + +5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: + + $ flake8 progressbar tests + $ py.test + $ tox + + To get flake8 and tox, just pip install them into your virtualenv using the requirements file. + + $ pip install -r tests/requirements.txt + +6. Commit your changes and push your branch to GitHub with `git-flow-avh`_:: + + $ git add . + $ git commit -m "Your detailed description of your changes." + $ git-flow feature publish + + Or without git-flow: + + $ git add . + $ git commit -m "Your detailed description of your changes." + $ git push -u origin feature/name-of-your-bugfix-or-feature + +7. Submit a pull request through the GitHub website. + +Pull Request Guidelines +----------------------- + +Before you submit a pull request, check that it meets these guidelines: + +1. The pull request should include tests. +2. If the pull request adds functionality, the docs should be updated. Put + your new functionality into a function with a docstring, and add the + feature to the list in README.rst. +3. The pull request should work for Python 2.7, 3.3, and for PyPy. Check + https://travis-ci.org/WoLpH/python-progressbar/pull_requests + and make sure that the tests pass for all supported Python versions. + +Tips +---- + +To run a subset of tests:: + + $ py.test tests/some_test.py + +.. _git-flow-avh: https://github.com/petervanderdoes/gitflow diff --git a/ChangeLog.yaml b/ChangeLog.yaml deleted file mode 100644 index 01da7e42..00000000 --- a/ChangeLog.yaml +++ /dev/null @@ -1,66 +0,0 @@ -2011-05-15: - - Removed parse errors for Python2.4 (no, people *should not* be using it - but it is only 3 years old and it does not have that many differences) - - - split up progressbar.py into logical units while maintaining backwards - compatability - - - Removed MANIFEST.in because it is no longer needed and it was causing - distribute to show warnings - - -2011-05-14: - - Changes to directory structure so pip can install from Google Code - - Python 3.x related fixes (all examples work on Python 3.1.3) - - Added counters, timers, and action bars for iterators with unknown length - -2010-08-29: - - Refactored some code and made it possible to use a ProgressBar as - an iterator (actually as an iterator that is a proxy to another iterator). - This simplifies showing a progress bar in a number of cases. - -2010-08-15: - - Did some minor changes to make it compatible with python 3. - -2009-05-31: - - Included check for calling start before update. - -2009-03-21: - - Improved FileTransferSpeed widget, which now supports an unit parameter, - defaulting to 'B' for bytes. It will also show B/s, MB/s, etc instead of - B/s, M/s, etc. - -2009-02-24: - - Updated licensing. - - Moved examples to separated file. - - Improved _need_update() method, which is now as fast as it can be. IOW, - no wasted cycles when an update is not needed. - -2008-12-22: - - Added SimpleProgress widget contributed by Sando Tosi - . - -2006-05-07: - - Fixed bug with terminal width in Windows. - - Released version 2.2. - -2005-12-04: - - Autodetection of terminal width. - - Added start method. - - Released version 2.1. - -2005-12-04: - - Everything is a widget now! - - Released version 2.0. - -2005-12-03: - - Rewrite using widgets. - - Released version 1.0. - -2005-06-02: - - Rewrite. - - Released version 0.5. - -2004-06-15: - - First version. - - Released version 0.1. diff --git a/ISSUE_TEMPLATE b/ISSUE_TEMPLATE new file mode 100644 index 00000000..ac4e32d4 --- /dev/null +++ b/ISSUE_TEMPLATE @@ -0,0 +1,15 @@ +#### Description + +Description of the problem + +#### Code + +If applicable, code to reproduce the issue and/or the stacktrace of the issue + +#### Versions + +- Python version: `import sys; print(sys.version)` +- Python distribution/environment: CPython/Anaconda/IPython/IDLE +- Operating System: Windows 10, Ubuntu Linux, etc. +- Package version: `import progressbar; print(progressbar.__version__)` + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..38887b7c --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2022, Rick van Hattem (Wolph) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of Python Progressbar nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index fc8ccdc1..00000000 --- a/LICENSE.txt +++ /dev/null @@ -1,52 +0,0 @@ -You can redistribute and/or modify this library under the terms of the -GNU LGPL license or BSD license (or both). - ---- - -progressbar - Text progress bar library for python. -Copyright (C) 2005 Nilton Volpato - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation; either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - ---- - -progressbar - Text progress bar library for python -Copyright (c) 2008 Nilton Volpato - -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - a. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - b. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - c. Neither the name of the author nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY -OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH -DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in index a1d7b673..f387924e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,12 @@ -include README.txt LICENSE.txt +recursive-exclude *.pyc +recursive-exclude *.pyo +recursive-exclude *.html +include AUTHORS.rst +include CHANGES.rst +include CONTRIBUTING.rst +include LICENSE +include README.rst include examples.py +include requirements.txt +include Makefile +include pytest.ini diff --git a/README.rst b/README.rst index a718a3f1..25a630c8 100644 --- a/README.rst +++ b/README.rst @@ -1,53 +1,354 @@ +############################################################################## Text progress bar library for Python. -===================================== +############################################################################## -Introduction ------------- +Build status: + +.. 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.svg?branch=master + :target: https://coveralls.io/r/WoLpH/python-progressbar?branch=master + +****************************************************************************** +Install +****************************************************************************** + +The package can be installed through `pip` (this is the recommended method): -.. highlights:: + pip install progressbar2 - **NOTE:** This version has been completely rewritten and might not be - 100% compatible with the old version. If you encounter any problems - while using it please let me know: - https://github.com/WoLpH/python-progressbar/issues +Or if `pip` is not available, `easy_install` should work as well: + + easy_install progressbar2 + +Or download the latest release from Pypi (https://pypi.python.org/pypi/progressbar2) or Github. + +Note that the releases on Pypi are signed with my GPG key (https://pgp.mit.edu/pks/lookup?op=vindex&search=0xE81444E9CE1F695D) and can be checked using GPG: + + gpg --verify progressbar2-.tar.gz.asc progressbar2-.tar.gz + +****************************************************************************** +Introduction +****************************************************************************** A text progress bar is typically used to display the progress of a long running operation, providing a visual cue that processing is underway. +The progressbar is based on the old Python progressbar package that was published on the now defunct Google Code. Since that project was completely abandoned by its developer and the developer did not respond to email, I decided to fork the package. This package is still backwards compatible with the original progressbar package so you can safely use it as a drop-in replacement for existing project. + The ProgressBar class manages the current progress, and the format of the line is given by a number of widgets. A widget is an object that may display differently depending on the state of the progress bar. There are many types of widgets: - - `Timer` - - `ETA` - - `AdaptiveETA` - - `FileTransferSpeed` - - `AdaptiveTransferSpeed` - - `AnimatedMarker` - - `Counter` - - `Percentage` - - `FormatLabel` - - `SimpleProgress` - - `Bar` - - `ReverseBar` - - `BouncingBar` - - `RotatingMarker` + - `AbsoluteETA `_ + - `AdaptiveETA `_ + - `AdaptiveTransferSpeed `_ + - `AnimatedMarker `_ + - `Bar `_ + - `BouncingBar `_ + - `Counter `_ + - `CurrentTime `_ + - `DataSize `_ + - `DynamicMessage `_ + - `ETA `_ + - `FileTransferSpeed `_ + - `FormatCustomText `_ + - `FormatLabel `_ + - `FormatLabelBar `_ + - `GranularBar `_ + - `Percentage `_ + - `PercentageLabelBar `_ + - `ReverseBar `_ + - `RotatingMarker `_ + - `SimpleProgress `_ + - `SmoothingETA `_ + - `Timer `_ The progressbar module is very easy to use, yet very powerful. It will also automatically enable features like auto-resizing when the system supports it. +****************************************************************************** +Known issues +****************************************************************************** + +- The Jetbrains (PyCharm, etc) editors work out of the box, but for more advanced features such as the `MultiBar` support you will need to enable the "Enable terminal in output console" checkbox in the Run dialog. +- The IDLE editor doesn't support these types of progress bars at all: https://bugs.python.org/issue23220 +- Jupyter notebooks buffer `sys.stdout` which can cause mixed output. This issue can be resolved easily using: `import sys; sys.stdout.flush()`. Linked issue: https://github.com/WoLpH/python-progressbar/issues/173 + +****************************************************************************** Links ------ +****************************************************************************** * Documentation - - 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 +****************************************************************************** + +There are many ways to use Python Progressbar, you can see a few basic examples +here but there are many more in the examples file. + +Wrapping an iterable +============================================================================== +.. code:: python + + import time + import progressbar + + for i in progressbar.progressbar(range(100)): + time.sleep(0.02) + +Progressbars with logging +============================================================================== + +Progressbars with logging require `stderr` redirection _before_ the +`StreamHandler` is initialized. To make sure the `stderr` stream has been +redirected on time make sure to call `progressbar.streams.wrap_stderr()` before +you initialize the `logger`. + +One option to force early initialization is by using the `WRAP_STDERR` +environment variable, on Linux/Unix systems this can be done through: + +.. code:: sh + + # WRAP_STDERR=true python your_script.py + +If you need to flush manually while wrapping, you can do so using: + +.. code:: python + + import progressbar + + progressbar.streams.flush() + +In most cases the following will work as well, as long as you initialize the +`StreamHandler` after the wrapping has taken place. + +.. code:: python + + import time + import logging + import progressbar + + progressbar.streams.wrap_stderr() + logging.basicConfig() + + for i in progressbar.progressbar(range(10)): + logging.error('Got %d', i) + time.sleep(0.2) + +Multiple (threaded) progressbars +============================================================================== + +.. code:: python + + import random + import threading + import time + + import progressbar + + BARS = 5 + N = 50 + + + def do_something(bar): + for i in bar(range(N)): + # Sleep up to 0.1 seconds + time.sleep(random.random() * 0.1) + + # print messages at random intervals to show how extra output works + if random.random() > 0.9: + bar.print('random message for bar', bar, i) + + + with progressbar.MultiBar() as multibar: + for i in range(BARS): + # Get a progressbar + bar = multibar[f'Thread label here {i}'] + # Create a thread and pass the progressbar + threading.Thread(target=do_something, args=(bar,)).start() + +Context wrapper +============================================================================== +.. code:: python + + import time + import progressbar + + with progressbar.ProgressBar(max_value=10) as bar: + for i in range(10): + time.sleep(0.1) + bar.update(i) + +Combining progressbars with print output +============================================================================== +.. code:: python + + import time + import progressbar + + for i in progressbar.progressbar(range(100), redirect_stdout=True): + print('Some text', i) + time.sleep(0.1) + +Progressbar with unknown length +============================================================================== +.. code:: python + + import time + import progressbar + + bar = progressbar.ProgressBar(max_value=progressbar.UnknownLength) + for i in range(20): + time.sleep(0.1) + bar.update(i) + +Bar with custom widgets +============================================================================== +.. code:: python + + import time + import progressbar + + widgets=[ + ' [', progressbar.Timer(), '] ', + progressbar.Bar(), + ' (', progressbar.ETA(), ') ', + ] + for i in progressbar.progressbar(range(20), widgets=widgets): + time.sleep(0.1) + +Bar with wide Chinese (or other multibyte) characters +============================================================================== + +.. code:: python + + # vim: fileencoding=utf-8 + import time + import progressbar + + + def custom_len(value): + # These characters take up more space + characters = { + '进': 2, + '度': 2, + } + + total = 0 + for c in value: + total += characters.get(c, 1) + + return total + + + bar = progressbar.ProgressBar( + widgets=[ + '进度: ', + progressbar.Bar(), + ' ', + progressbar.Counter(format='%(value)02d/%(max_value)d'), + ], + len_func=custom_len, + ) + for i in bar(range(10)): + time.sleep(0.1) + +Showing multiple independent progress bars in parallel +============================================================================== + +.. code:: python + + import random + import sys + import time + + import progressbar + + BARS = 5 + N = 100 + + # Construct the list of progress bars with the `line_offset` so they draw + # below each other + bars = [] + for i in range(BARS): + bars.append( + progressbar.ProgressBar( + max_value=N, + # We add 1 to the line offset to account for the `print_fd` + line_offset=i + 1, + max_error=False, + ) + ) + + # Create a file descriptor for regular printing as well + print_fd = progressbar.LineOffsetStreamWrapper(lines=0, stream=sys.stdout) + + # The progress bar updates, normally you would do something useful here + for i in range(N * BARS): + time.sleep(0.005) + + # Increment one of the progress bars at random + bars[random.randrange(0, BARS)].increment() + + # Print a status message to the `print_fd` below the progress bars + print(f'Hi, we are at update {i+1} of {N * BARS}', file=print_fd) + + # Cleanup the bars + for bar in bars: + bar.finish() + + # Add a newline to make sure the next print starts on a new line + print() + +****************************************************************************** + +Naturally we can do this from separate threads as well: + +.. code:: python + + import random + import threading + import time + + import progressbar + + BARS = 5 + N = 100 + + # Create the bars with the given line offset + bars = [] + for line_offset in range(BARS): + bars.append(progressbar.ProgressBar(line_offset=line_offset, max_value=N)) + + + class Worker(threading.Thread): + def __init__(self, bar): + super().__init__() + self.bar = bar + + def run(self): + for i in range(N): + time.sleep(random.random() / 25) + self.bar.update(i) + + + for bar in bars: + Worker(bar).start() + print() diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000..91ffa33e --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,26 @@ +image: Visual Studio 2022 +environment: + matrix: + - PYTHON: "C:\\Python310-x64" + TOX_ENV: "py310" + - PYTHON: "C:\\Python311-x64" + TOX_ENV: "py311" + - PYTHON: "C:\\Python312-x64" + TOX_ENV: "py312" + - PYTHON: "C:\\Python313-x64" + TOX_ENV: "py313" + - PYTHON: "C:\\Python314-x64" + TOX_ENV: "py314" + + +init: + - "%PYTHON%\\python -V" + - "%PYTHON%\\python -c \"import struct;print(8 * struct.calcsize('P'))\"" + +install: + - "%PYTHON%\\python -m pip install --upgrade pip tox" + +build: false # Not a C# project, build stuff at the test step instead. + +test_script: + - "%PYTHON%\\python -m tox -e %TOX_ENV%" diff --git a/circle.yml b/circle.yml index 89e6af34..cd50d56f 100644 --- a/circle.yml +++ b/circle.yml @@ -1,6 +1,6 @@ dependencies: pre: - - pip install -r requirements_test.txt + - pip install -r tests/requirements.txt test: override: diff --git a/docs/_theme/LICENSE b/docs/_theme/LICENSE index f258ba03..7660d090 100644 --- a/docs/_theme/LICENSE +++ b/docs/_theme/LICENSE @@ -1,9 +1,9 @@ -Modifications: +Modifications: Copyright (c) 2012 Rick van Hattem. -Original Projects: +Original Projects: Copyright (c) 2010 Kenneth Reitz. Copyright (c) 2010 by Armin Ronacher. diff --git a/docs/_theme/flask_theme_support.py b/docs/_theme/flask_theme_support.py index 555c116d..81747125 100644 --- a/docs/_theme/flask_theme_support.py +++ b/docs/_theme/flask_theme_support.py @@ -1,86 +1,89 @@ # flasky extensions. flasky pygments style based on tango style from pygments.style import Style -from pygments.token import Keyword, Name, Comment, String, Error, \ - Number, Operator, Generic, Whitespace, Punctuation, Other, Literal +from pygments.token import ( + Comment, + Error, + Generic, + Keyword, + Literal, + Name, + Number, + Operator, + Other, + Punctuation, + String, + Whitespace, +) class FlaskyStyle(Style): - background_color = "#f8f8f8" - default_style = "" + background_color = '#f8f8f8' + default_style = '' styles = { # No corresponding class for the following: - # Text: "", # class: '' - Whitespace: "underline #f8f8f8", # class: 'w' - Error: "#a40000 border:#ef2929", # class: 'err' - Other: "#000000", # class 'x' - - Comment: "italic #8f5902", # class: 'c' - Comment.Preproc: "noitalic", # class: 'cp' - - Keyword: "bold #004461", # class: 'k' - Keyword.Constant: "bold #004461", # class: 'kc' - Keyword.Declaration: "bold #004461", # class: 'kd' - Keyword.Namespace: "bold #004461", # class: 'kn' - Keyword.Pseudo: "bold #004461", # class: 'kp' - Keyword.Reserved: "bold #004461", # class: 'kr' - Keyword.Type: "bold #004461", # class: 'kt' - - Operator: "#582800", # class: 'o' - Operator.Word: "bold #004461", # class: 'ow' - like keywords - - Punctuation: "bold #000000", # class: 'p' - + # Text: '', # class: '' + Whitespace: 'underline #f8f8f8', # class: 'w' + Error: '#a40000 border:#ef2929', # class: 'err' + Other: '#000000', # class 'x' + Comment: 'italic #8f5902', # class: 'c' + Comment.Preproc: 'noitalic', # class: 'cp' + Keyword: 'bold #004461', # class: 'k' + Keyword.Constant: 'bold #004461', # class: 'kc' + Keyword.Declaration: 'bold #004461', # class: 'kd' + Keyword.Namespace: 'bold #004461', # class: 'kn' + Keyword.Pseudo: 'bold #004461', # class: 'kp' + Keyword.Reserved: 'bold #004461', # class: 'kr' + Keyword.Type: 'bold #004461', # class: 'kt' + Operator: '#582800', # class: 'o' + Operator.Word: 'bold #004461', # class: 'ow' - like keywords + Punctuation: 'bold #000000', # class: 'p' # because special names such as Name.Class, Name.Function, etc. # are not recognized as such later in the parsing, we choose them # to look the same as ordinary variables. - Name: "#000000", # class: 'n' - Name.Attribute: "#c4a000", # class: 'na' - to be revised - Name.Builtin: "#004461", # class: 'nb' - Name.Builtin.Pseudo: "#3465a4", # class: 'bp' - Name.Class: "#000000", # class: 'nc' - to be revised - Name.Constant: "#000000", # class: 'no' - to be revised - Name.Decorator: "#888", # class: 'nd' - to be revised - Name.Entity: "#ce5c00", # class: 'ni' - Name.Exception: "bold #cc0000", # class: 'ne' - Name.Function: "#000000", # class: 'nf' - Name.Property: "#000000", # class: 'py' - Name.Label: "#f57900", # class: 'nl' - Name.Namespace: "#000000", # class: 'nn' - to be revised - Name.Other: "#000000", # class: 'nx' - Name.Tag: "bold #004461", # class: 'nt' - like a keyword - Name.Variable: "#000000", # class: 'nv' - to be revised - Name.Variable.Class: "#000000", # class: 'vc' - to be revised - Name.Variable.Global: "#000000", # class: 'vg' - to be revised - Name.Variable.Instance: "#000000", # class: 'vi' - to be revised - - Number: "#990000", # class: 'm' - - Literal: "#000000", # class: 'l' - Literal.Date: "#000000", # class: 'ld' - - String: "#4e9a06", # class: 's' - String.Backtick: "#4e9a06", # class: 'sb' - String.Char: "#4e9a06", # class: 'sc' - String.Doc: "italic #8f5902", # class: 'sd' - like a comment - String.Double: "#4e9a06", # class: 's2' - String.Escape: "#4e9a06", # class: 'se' - String.Heredoc: "#4e9a06", # class: 'sh' - String.Interpol: "#4e9a06", # class: 'si' - String.Other: "#4e9a06", # class: 'sx' - String.Regex: "#4e9a06", # class: 'sr' - String.Single: "#4e9a06", # class: 's1' - String.Symbol: "#4e9a06", # class: 'ss' - - Generic: "#000000", # class: 'g' - Generic.Deleted: "#a40000", # class: 'gd' - Generic.Emph: "italic #000000", # class: 'ge' - Generic.Error: "#ef2929", # class: 'gr' - Generic.Heading: "bold #000080", # class: 'gh' - Generic.Inserted: "#00A000", # class: 'gi' - Generic.Output: "#888", # class: 'go' - Generic.Prompt: "#745334", # class: 'gp' - Generic.Strong: "bold #000000", # class: 'gs' - Generic.Subheading: "bold #800080", # class: 'gu' - Generic.Traceback: "bold #a40000", # class: 'gt' + Name: '#000000', # class: 'n' + Name.Attribute: '#c4a000', # class: 'na' - to be revised + Name.Builtin: '#004461', # class: 'nb' + Name.Builtin.Pseudo: '#3465a4', # class: 'bp' + Name.Class: '#000000', # class: 'nc' - to be revised + Name.Constant: '#000000', # class: 'no' - to be revised + Name.Decorator: '#888', # class: 'nd' - to be revised + Name.Entity: '#ce5c00', # class: 'ni' + Name.Exception: 'bold #cc0000', # class: 'ne' + Name.Function: '#000000', # class: 'nf' + Name.Property: '#000000', # class: 'py' + Name.Label: '#f57900', # class: 'nl' + Name.Namespace: '#000000', # class: 'nn' - to be revised + Name.Other: '#000000', # class: 'nx' + Name.Tag: 'bold #004461', # class: 'nt' - like a keyword + Name.Variable: '#000000', # class: 'nv' - to be revised + Name.Variable.Class: '#000000', # class: 'vc' - to be revised + Name.Variable.Global: '#000000', # class: 'vg' - to be revised + Name.Variable.Instance: '#000000', # class: 'vi' - to be revised + Number: '#990000', # class: 'm' + Literal: '#000000', # class: 'l' + Literal.Date: '#000000', # class: 'ld' + String: '#4e9a06', # class: 's' + String.Backtick: '#4e9a06', # class: 'sb' + String.Char: '#4e9a06', # class: 'sc' + String.Doc: 'italic #8f5902', # class: 'sd' - like a comment + String.Double: '#4e9a06', # class: 's2' + String.Escape: '#4e9a06', # class: 'se' + String.Heredoc: '#4e9a06', # class: 'sh' + String.Interpol: '#4e9a06', # class: 'si' + String.Other: '#4e9a06', # class: 'sx' + String.Regex: '#4e9a06', # class: 'sr' + String.Single: '#4e9a06', # class: 's1' + String.Symbol: '#4e9a06', # class: 'ss' + Generic: '#000000', # class: 'g' + Generic.Deleted: '#a40000', # class: 'gd' + Generic.Emph: 'italic #000000', # class: 'ge' + Generic.Error: '#ef2929', # class: 'gr' + Generic.Heading: 'bold #000080', # class: 'gh' + Generic.Inserted: '#00A000', # class: 'gi' + Generic.Output: '#888', # class: 'go' + Generic.Prompt: '#745334', # class: 'gp' + Generic.Strong: 'bold #000000', # class: 'gs' + Generic.Subheading: 'bold #800080', # class: 'gu' + Generic.Traceback: 'bold #a40000', # class: 'gt' } diff --git a/docs/_theme/wolph/theme.conf b/docs/_theme/wolph/theme.conf index 307a1f0d..07698f6f 100644 --- a/docs/_theme/wolph/theme.conf +++ b/docs/_theme/wolph/theme.conf @@ -4,4 +4,4 @@ stylesheet = flasky.css pygments_style = flask_theme_support.FlaskyStyle [options] -touch_icon = +touch_icon = diff --git a/docs/conf.py b/docs/conf.py index ed7e1480..a769e40c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,5 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + # # Progress Bar documentation build configuration file, created by # sphinx-quickstart on Tue Aug 20 11:47:33 2013. @@ -10,17 +11,16 @@ # # All configuration values have a default; values that are commented out # serve to show the default. - +import datetime import os import sys -import datetime # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) -import progressbar +from progressbar import __about__ as metadata # -- General configuration ----------------------------------------------- @@ -35,9 +35,17 @@ 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', + 'sphinx.ext.mathjax', + 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', ] +suppress_warnings = [ + 'image.nonlocal_uri', +] + +needs_sphinx = '1.4' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -51,21 +59,18 @@ master_doc = 'index' # General information about the project. -project = u'Progress Bar' -project_slug = ''.join(project.capitalize().split()) -copyright = u'%s, %s' % ( - datetime.date.today().year, - progressbar.__author__, -) +project = 'Progress Bar' +project_slug: str = ''.join(project.capitalize().split()) +copyright = f'{datetime.date.today().year}, {metadata.__author__}' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = progressbar.__version__ +version: str = metadata.__version__ # The full version, including alpha/beta/rc tags. -release = progressbar.__version__ +release: str = metadata.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -93,7 +98,7 @@ # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -# show_authors = False +show_authors = True # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' @@ -182,7 +187,7 @@ # html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = '%sdoc' % project_slug +htmlhelp_basename = f'{project_slug}doc' # -- Options for LaTeX output -------------------------------------------- @@ -190,19 +195,22 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ('index', '%s.tex' % project_slug, u'%s Documentation' % project, - progressbar.__author__, 'manual'), +latex_documents: list[tuple[str, ...]] = [ + ( + 'index', + f'{project_slug}.tex', + f'{project} Documentation', + metadata.__author__, + 'manual', + ) ] # The name of an image file (relative to this directory) to place at the top of @@ -230,9 +238,14 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', project_slug.lower(), u'%s Documentation' % project, - [progressbar.__author__], 1) +man_pages: list[tuple[str, str, str, list[str], int]] = [ + ( + 'index', + project_slug.lower(), + f'{project} Documentation', + [metadata.__author__], + 1, + ) ] # If true, show URL addresses after external links. @@ -244,10 +257,16 @@ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) -texinfo_documents = [ - ('index', project_slug, u'%s Documentation' % project, - progressbar.__author__, project_slug, 'One line description of project.', - 'Miscellaneous'), +texinfo_documents: list[tuple[str, ...]] = [ + ( + 'index', + project_slug, + f'{project} Documentation', + metadata.__author__, + project_slug, + 'One line description of project.', + 'Miscellaneous', + ) ] # Documents to append as an appendix to all manuals. @@ -266,10 +285,10 @@ # -- Options for Epub output --------------------------------------------- # Bibliographic Dublin Core info. -epub_title = project -epub_author = progressbar.__author__ -epub_publisher = progressbar.__author__ -epub_copyright = copyright +epub_title: str = project +epub_author: str = metadata.__author__ +epub_publisher: str = metadata.__author__ +epub_copyright: str = copyright # The language of the text. It defaults to the language option # or en if the language is not set. @@ -295,7 +314,7 @@ # The format is a list of tuples containing the path and title. # epub_pre_files = [] -# HTML files shat should be inserted after the pages created by sphinx. +# HTML files that should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. # epub_post_files = [] @@ -322,4 +341,6 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/': None} +intersphinx_mapping: dict[str, tuple[str, None]] = { + 'python': ('https://docs.python.org/3', None) +} diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 00000000..e582053e --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1 @@ +.. include:: ../CONTRIBUTING.rst diff --git a/docs/history.rst b/docs/history.rst new file mode 100644 index 00000000..91f04cb8 --- /dev/null +++ b/docs/history.rst @@ -0,0 +1,8 @@ +.. _history: + +======= +History +======= + +.. include:: ../CHANGES.rst + :start-line: 5 diff --git a/docs/index.rst b/docs/index.rst index ea9e308a..58b16d58 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,19 +1,26 @@ +======================================== Welcome to Progress Bar's documentation! ======================================== -.. include:: ../README.rst - -Contents ------------------------------------------------------------------------------- - .. toctree:: - :maxdepth: 2 + :maxdepth: 4 + usage examples - progressbar + contributing + installation + progressbar.shortcuts + progressbar.bar + progressbar.base + progressbar.utils + progressbar.widgets + history + +.. include:: ../README.rst +****************** Indices and tables -================== +****************** * :ref:`genindex` * :ref:`modindex` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 00000000..fefb40bd --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,16 @@ +============ +Installation +============ + +At the command line:: + + $ pip install progressbar2 + +Or if you don't have pip:: + + $ easy_install progressbar2 + +Or, if you have virtualenvwrapper installed:: + + $ mkvirtualenv progressbar2 + $ pip install progressbar2 diff --git a/docs/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.base.rst b/docs/progressbar.base.rst new file mode 100644 index 00000000..6b9265ba --- /dev/null +++ b/docs/progressbar.base.rst @@ -0,0 +1,7 @@ +progressbar.base module +======================= + +.. automodule:: progressbar.base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.env.rst b/docs/progressbar.env.rst new file mode 100644 index 00000000..a818e0b1 --- /dev/null +++ b/docs/progressbar.env.rst @@ -0,0 +1,7 @@ +progressbar.env module +====================== + +.. automodule:: progressbar.env + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.metadata.rst b/docs/progressbar.metadata.rst deleted file mode 100644 index e94c7bdd..00000000 --- a/docs/progressbar.metadata.rst +++ /dev/null @@ -1,7 +0,0 @@ -progressbar.metadata module -=========================== - -.. automodule:: progressbar.metadata - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/progressbar.multi.rst b/docs/progressbar.multi.rst new file mode 100644 index 00000000..5d8b85fd --- /dev/null +++ b/docs/progressbar.multi.rst @@ -0,0 +1,7 @@ +progressbar.multi module +======================== + +.. automodule:: progressbar.multi + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.rst b/docs/progressbar.rst index 3eb4669d..674f6b64 100644 --- a/docs/progressbar.rst +++ b/docs/progressbar.rst @@ -1,20 +1,31 @@ progressbar package =================== +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal + Submodules ---------- .. toctree:: + :maxdepth: 4 progressbar.bar - progressbar.widgets + progressbar.base + progressbar.multi + progressbar.shortcuts progressbar.utils - progressbar.six + progressbar.widgets Module contents --------------- .. automodule:: progressbar - :members: - :undoc-members: - :show-inheritance: + :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/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..62d6cd8a --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +-e.[docs,tests] diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 00000000..6aa03303 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,70 @@ +======== +Usage +======== + +There are many ways to use Python Progressbar, you can see a few basic examples +here but there are many more in the :doc:`examples` file. + +Wrapping an iterable +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + bar = progressbar.ProgressBar() + for i in bar(range(100)): + time.sleep(0.02) + +Context wrapper +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + with progressbar.ProgressBar(max_value=10) as bar: + for i in range(10): + time.sleep(0.1) + bar.update(i) + +Combining progressbars with print output +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + bar = progressbar.ProgressBar(redirect_stdout=True) + for i in range(100): + print 'Some text', i + time.sleep(0.1) + bar.update(i) + +Progressbar with unknown length +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + bar = progressbar.ProgressBar(max_value=progressbar.UnknownLength) + for i in range(20): + time.sleep(0.1) + bar.update(i) + +Bar with custom widgets +------------------------------------------------------------------------------ +:: + + import time + import progressbar + + bar = progressbar.ProgressBar(widgets=[ + ' [', progressbar.Timer(), '] ', + progressbar.Bar(), + ' (', progressbar.ETA(), ') ', + ]) + for i in bar(range(20)): + time.sleep(0.1) + diff --git a/examples.py b/examples.py index 50a95367..eb2953d2 100644 --- a/examples.py +++ b/examples.py @@ -1,360 +1,872 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- - -from __future__ import print_function +from __future__ import annotations +import contextlib +import functools +import os +import random import sys import time +import typing -from progressbar import AnimatedMarker, Bar, BouncingBar, Counter, ETA, \ - FileTransferSpeed, FormatLabel, Percentage, \ - ProgressBar, ReverseBar, RotatingMarker, \ - SimpleProgress, Timer, AdaptiveETA, AdaptiveTransferSpeed +import progressbar -examples = [] +examples: list[typing.Callable[[typing.Any], typing.Any]] = [] def example(fn): - def wrapped(): + """Wrap the examples so they generate readable output""" + + @functools.wraps(fn) + def wrapped(*args, **kwargs): try: - sys.stdout.write('Running: %s\n' % fn.__name__) - fn() + sys.stdout.write(f'Running: {fn.__name__}\n') + fn(*args, **kwargs) sys.stdout.write('\n') except KeyboardInterrupt: sys.stdout.write('\nSkipping example.\n\n') + # Sleep a bit to make killing the script easier + time.sleep(0.2) examples.append(wrapped) return wrapped @example -def with_example0(): - with ProgressBar(max_value=10) as progress: - for i in range(10): - # do something - time.sleep(0.001) - progress.update(i) +def fast_example() -> None: + """Updates bar really quickly to cause flickering""" + with progressbar.ProgressBar(widgets=[progressbar.Bar()]) as bar: + for i in range(100): + bar.update(int(i / 10), force=True) @example -def with_example1(): - with ProgressBar(max_value=10, redirect_stdout=True) as p: +def shortcut_example() -> None: + for _ in progressbar.progressbar(range(10)): + time.sleep(0.1) + + +@example +def prefixed_shortcut_example() -> None: + for _ in progressbar.progressbar(range(10), prefix='Hi: '): + time.sleep(0.1) + + +@example +def parallel_bars_multibar_example() -> None: + if os.name == 'nt': + print( + 'Skipping multibar example on Windows due to threading ' + 'incompatibilities with the example code.' + ) + return + + BARS = 5 + N = 50 + + def do_something(bar): + for _ in bar(range(N)): + # Sleep up to 0.1 seconds + time.sleep(random.random() * 0.1) + + with progressbar.MultiBar() as multibar: + bar_labels = [] + for i in range(BARS): + # Get a progressbar + bar_label = 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() + + # The multibar context manager waits for all bars to finish on + # exit, so finish them explicitly + for bar_label in bar_labels: + multibar[bar_label].finish() + + +@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(f'Some print statement {i:d}') # do something p.update(i) - time.sleep(0.001) + time.sleep(0.1) @example -def example0(): - pbar = ProgressBar(widgets=[Percentage(), Bar()], max_value=10).start() +def basic_widget_example() -> None: + widgets = [progressbar.Percentage(), progressbar.Bar()] + bar = progressbar.ProgressBar(widgets=widgets, max_value=10).start() for i in range(10): # do something - time.sleep(0.001) - pbar.update(i + 1) - pbar.finish() + time.sleep(0.1) + bar.update(i + 1) + bar.finish() @example -def example1(): - widgets = ['Test: ', Percentage(), ' ', Bar(marker=RotatingMarker()), - ' ', ETA(), ' ', FileTransferSpeed()] - pbar = ProgressBar(widgets=widgets, max_value=1000).start() - for i in range(100): +def color_bar_example() -> None: + widgets = [ + '\x1b[33mColorful example\x1b[39m', + progressbar.Percentage(), + progressbar.Bar(marker='\x1b[32m#\x1b[39m'), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=10).start() + for i in range(10): + # do something + 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 - pbar.update(10 * i + 1) - pbar.finish() + time.sleep(0.1) + bar.update(i + 1) + bar.finish() @example -def example2(): - class CrazyFileTransferSpeed(FileTransferSpeed): +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() + - "It's bigger between 45 and 80 percent" +@example +def custom_file_transfer_example() -> None: + class CrazyFileTransferSpeed(progressbar.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) + def update(self, bar): + if 45 < bar.percentage() < 80: + return 'Bigger Now ' + progressbar.FileTransferSpeed.update( + self, bar + ) else: - return FileTransferSpeed.update(self, pbar) + return progressbar.FileTransferSpeed.update(self, bar) - widgets = [CrazyFileTransferSpeed(), ' <<<', Bar(), '>>> ', - Percentage(), ' ', ETA()] - pbar = ProgressBar(widgets=widgets, max_value=1000) + widgets = [ + CrazyFileTransferSpeed(), + ' <<<', + progressbar.Bar(), + '>>> ', + progressbar.Percentage(), + ' ', + progressbar.ETA(), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=1000) # maybe do something - pbar.start() + bar.start() for i in range(200): # do something - pbar.update(5 * i + 1) - pbar.finish() + time.sleep(0.01) + bar.update(5 * i + 1) + bar.finish() @example -def example3(): - widgets = [Bar('>'), ' ', ETA(), ' ', ReverseBar('<')] - pbar = ProgressBar(widgets=widgets, max_value=1000).start() +def double_bar_example() -> None: + widgets = [ + progressbar.Bar('>'), + ' ', + progressbar.ETA(), + ' ', + progressbar.ReverseBar('<'), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=1000).start() for i in range(100): # do something - pbar.update(10 * i + 1) - pbar.finish() + time.sleep(0.01) + bar.update(10 * i + 1) + bar.finish() @example -def example4(): - widgets = ['Test: ', Percentage(), ' ', - Bar(marker='0', left='[', right=']'), - ' ', ETA(), ' ', FileTransferSpeed()] - pbar = ProgressBar(widgets=widgets, max_value=500) - pbar.start() - for i in range(100, 500 + 1, 50): - time.sleep(0.001) - pbar.update(i) - pbar.finish() +def basic_file_transfer() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker='0', left='[', right=']'), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=500) + bar.start() + # Go beyond the max_value + for i in range(100, 501, 50): + time.sleep(0.1) + bar.update(i) + bar.finish() @example -def example5(): - pbar = ProgressBar(widgets=[SimpleProgress()], max_value=17).start() +def simple_progress() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.SimpleProgress()], + max_value=17, + ).start() for i in range(17): - time.sleep(0.001) - pbar.update(i + 1) - pbar.finish() + time.sleep(0.1) + bar.update(i + 1) + bar.finish() @example -def example6(): - pbar = ProgressBar().start() +def basic_progress() -> None: + bar = progressbar.ProgressBar().start() for i in range(10): - time.sleep(0.001) - pbar.update(i + 1) - pbar.finish() + time.sleep(0.1) + bar.update(i + 1) + bar.finish() @example -def example7(): - pbar = ProgressBar() # Progressbar can guess max_value automatically. - for i in pbar(range(8)): - time.sleep(0.001) +def progress_with_automatic_max() -> None: + # Progressbar can guess max_value automatically. + bar = progressbar.ProgressBar() + for _ in bar(range(8)): + time.sleep(0.1) @example -def example8(): - pbar = ProgressBar(max_value=8) # Progressbar can't guess max_value. - for i in pbar((i for i in range(8))): - time.sleep(0.001) +def progress_with_unavailable_max() -> None: + # Progressbar can't guess max_value. + bar = progressbar.ProgressBar(max_value=8) + for _ in bar(i for i in range(8)): + time.sleep(0.1) @example -def example9(): - pbar = ProgressBar(widgets=['Working: ', AnimatedMarker()]) - for i in pbar((i for i in range(5))): - time.sleep(0.001) +def animated_marker() -> None: + bar = progressbar.ProgressBar( + widgets=['Working: ', progressbar.AnimatedMarker()] + ) + for _ in bar(i for i in range(5)): + time.sleep(0.1) @example -def example10(): - widgets = ['Processed: ', Counter(), ' lines (', Timer(), ')'] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(15))): - time.sleep(0.001) +def filling_bar_animated_marker() -> None: + bar = progressbar.ProgressBar( + widgets=[ + progressbar.Bar( + marker=progressbar.AnimatedMarker(fill='#'), + ), + ] + ) + for _ in bar(range(15)): + time.sleep(0.1) @example -def example11(): - widgets = [FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(15))): - time.sleep(0.001) +def counter_and_timer() -> None: + widgets = [ + 'Processed: ', + progressbar.Counter('Counter: %(value)05d'), + ' lines (', + progressbar.Timer(), + ')', + ] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(15)): + time.sleep(0.1) @example -def example12(): - widgets = ['Balloon: ', AnimatedMarker(markers='.oO@* ')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) +def format_label() -> None: + widgets = [ + progressbar.FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)') + ] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(15)): + time.sleep(0.1) + + +@example +def animated_balloons() -> None: + widgets = ['Balloon: ', progressbar.AnimatedMarker(markers='.oO@* ')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) @example -def example13(): +def animated_arrows() -> None: # You may need python 3.x to see this correctly try: - widgets = ['Arrows: ', AnimatedMarker(markers='←↖↑↗→↘↓↙')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) + widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='←↖↑↗→↘↓↙')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def example14(): +def animated_filled_arrows() -> None: # You may need python 3.x to see this correctly try: - widgets = ['Arrows: ', AnimatedMarker(markers='◢◣◤◥')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) + widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='◢◣◤◥')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def example15(): +def animated_wheels() -> None: # You may need python 3.x to see this correctly try: - widgets = ['Wheels: ', AnimatedMarker(markers='◐◓◑◒')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) + widgets = ['Wheels: ', progressbar.AnimatedMarker(markers='◐◓◑◒')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def example16(): - widgets = [FormatLabel('Bouncer: value %(value)d - '), BouncingBar()] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(100))): - time.sleep(0.001) +def format_label_bouncer() -> None: + widgets = [ + progressbar.FormatLabel('Bouncer: value %(value)d - '), + progressbar.BouncingBar(), + ] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(100)): + time.sleep(0.01) @example -def example17(): - widgets = [FormatLabel('Animated Bouncer: value %(value)d - '), - BouncingBar(marker=RotatingMarker())] +def format_label_rotating_bouncer() -> None: + widgets = [ + progressbar.FormatLabel('Animated Bouncer: value %(value)d - '), + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(18))): - time.sleep(0.001) + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(18)): + time.sleep(0.1) @example -def with_example18(): - with ProgressBar(max_value=10, term_width=20, left_justify=False) as \ - progress: - assert progress._env_size() is not None +def with_right_justify() -> None: + with progressbar.ProgressBar( + max_value=10, term_width=20, left_justify=False + ) as progress: + assert progress.term_width is not None for i in range(10): progress.update(i) @example -def with_example19(): - with ProgressBar(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 with_example20(): - progress = ProgressBar(max_value=1) - try: +def reaching_maximum() -> None: + progress = progressbar.ProgressBar(max_value=1) + with contextlib.suppress(RuntimeError): progress.update(1) - except RuntimeError: - pass @example -def with_example21a(): - with ProgressBar(max_value=1, redirect_stdout=True) as progress: +def stdout_redirection() -> None: + with progressbar.ProgressBar(redirect_stdout=True) as progress: print('', file=sys.stdout) progress.update(0) @example -def with_example21b(): - with ProgressBar(max_value=1, redirect_stderr=True) as progress: +def stderr_redirection() -> None: + with progressbar.ProgressBar(redirect_stderr=True) as progress: print('', file=sys.stderr) progress.update(0) @example -def with_example22(): - try: - with ProgressBar(max_value=-1) as progress: - progress.start() - except ValueError: - pass - - -@example -def example23(): - widgets = [BouncingBar(marker=RotatingMarker())] - with ProgressBar(widgets=widgets, max_value=20, term_width=10) as progress: +def rotating_bouncing_marker() -> None: + widgets = [progressbar.BouncingBar(marker=progressbar.RotatingMarker())] + with progressbar.ProgressBar( + widgets=widgets, max_value=20, term_width=10 + ) as progress: for i in range(20): + time.sleep(0.1) progress.update(i) - widgets = [BouncingBar(marker=RotatingMarker(), fill_left=False)] - with ProgressBar(widgets=widgets, 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): + time.sleep(0.1) progress.update(i) @example -def example24(): - pbar = ProgressBar(widgets=[Percentage(), 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 - time.sleep(0.001) - pbar += 1 - pbar.finish() + time.sleep(0.1) + bar += 1 + bar.finish() @example -def example25(): - widgets = ['Test: ', Percentage(), ' ', Bar(marker=RotatingMarker()), - ' ', ETA(), ' ', FileTransferSpeed()] - pbar = ProgressBar(widgets=widgets, max_value=1000, - redirect_stdout=True).start() - for i in range(100): +def increment_bar_with_output_redirection() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + bar = progressbar.ProgressBar( + widgets=widgets, max_value=100, redirect_stdout=True + ).start() + for i in range(10): # do something - pbar += 10 - pbar.finish() + time.sleep(0.01) + bar += 10 + print('Got', i) + bar.finish() @example -def example26(): +def eta_types_demonstration() -> None: widgets = [ - Percentage(), - ' ', Bar(), - ' ', ETA(), - ' ', AdaptiveETA(), - ' ', AdaptiveTransferSpeed(), + progressbar.Percentage(), + ' ETA: ', + progressbar.ETA(), + ' Adaptive : ', + progressbar.AdaptiveETA(), + ' Smoothing(a=0.1): ', + progressbar.SmoothingETA(smoothing_parameters=dict(alpha=0.1)), + ' Smoothing(a=0.9): ', + progressbar.SmoothingETA(smoothing_parameters=dict(alpha=0.9)), + ' Absolute: ', + progressbar.AbsoluteETA(), + ' Transfer: ', + progressbar.FileTransferSpeed(), + ' Adaptive T: ', + progressbar.AdaptiveTransferSpeed(), + ' ', + progressbar.Bar(), ] - pbar = ProgressBar(widgets=widgets, max_value=500) - pbar.start() + bar = progressbar.ProgressBar(widgets=widgets, max_value=500) + bar.start() for i in range(500): - time.sleep(0.001 + (i < 100) * 0.0001 + (i > 400) * 0.009) - pbar.update(i + 1) - pbar.finish() + if i < 100: + time.sleep(0.02) + elif i > 400: + time.sleep(0.1) + else: + time.sleep(0.01) + bar.update(i + 1) + bar.finish() + + +@example +def adaptive_eta_without_value_change() -> None: + # Testing progressbar.AdaptiveETA when the value doesn't actually change + bar = progressbar.ProgressBar( + widgets=[ + progressbar.AdaptiveETA(), + progressbar.AdaptiveTransferSpeed(), + ], + max_value=2, + poll_interval=0.0001, + ) + bar.start() + for _ in range(100): + bar.update(1) + time.sleep(0.1) + bar.finish() + + +@example +def iterator_with_max_value() -> None: + # Testing using progressbar as an iterator with a max value + bar = progressbar.ProgressBar() + + for _ in bar(iter(range(100)), 100): + # iter range is a way to get an iterator in both python 2 and 3 + time.sleep(0.01) @example -def example27(): - # Testing AdaptiveETA when the value doesn't actually change - pbar = ProgressBar(widgets=[AdaptiveETA(), AdaptiveTransferSpeed()], - max_value=2, poll=0.0001) - pbar.start() - pbar.update(1) - time.sleep(0.001) - pbar.update(1) - pbar.finish() +def eta() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' | ETA: ', + progressbar.ETA(), + ' | AbsoluteETA: ', + progressbar.AbsoluteETA(), + ' | AdaptiveETA: ', + progressbar.AdaptiveETA(), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=50).start() + for i in range(50): + time.sleep(0.1) + bar.update(i + 1) + bar.finish() @example -def example28(): - # Testing using progressbar as an iterator with a max value - pbar = ProgressBar() +def variables() -> None: + # Use progressbar.Variable to keep track of some parameter(s) during + # your calculations + widgets = [ + progressbar.Percentage(), + progressbar.Bar(), + progressbar.Variable('loss'), + ', ', + progressbar.Variable('username', width=12, precision=12), + ] + with progressbar.ProgressBar(max_value=100, widgets=widgets) as bar: + min_so_far = 1 + for i in range(100): + time.sleep(0.01) + val = random.random() + if val < min_so_far: + min_so_far = val + bar.update(i, loss=min_so_far, username='Some user') + + +@example +def user_variables() -> None: + tasks = { + 'Download': [ + 'SDK', + 'IDE', + 'Dependencies', + ], + 'Build': [ + 'Compile', + 'Link', + ], + 'Test': [ + 'Unit tests', + 'Integration tests', + 'Regression tests', + ], + 'Deploy': [ + 'Send to server', + 'Restart server', + ], + } + num_subtasks = sum(len(x) for x in tasks.values()) + + with progressbar.ProgressBar( + prefix='{variables.task} >> {variables.subtask}', + variables={'task': '--', 'subtask': '--'}, + max_value=10 * num_subtasks, + ) as bar: + for tasks_name, subtasks in tasks.items(): + for subtask_name in subtasks: + for _ in range(10): + bar.update( + bar.value + 1, task=tasks_name, subtask=subtask_name + ) + time.sleep(0.1) + + +@example +def format_custom_text() -> None: + format_custom_text = progressbar.FormatCustomText( + 'Spam: %(spam).1f kg, eggs: %(eggs)d', + dict( + spam=0.25, + eggs=3, + ), + ) + + bar = progressbar.ProgressBar( + widgets=[ + format_custom_text, + ' :: ', + progressbar.Percentage(), + ] + ) + for i in bar(range(25)): + format_custom_text.update_mapping(eggs=i * 2) + time.sleep(0.1) + + +@example +def simple_api_example() -> None: + bar = progressbar.ProgressBar(widget_kwargs=dict(fill='█')) + for _ in bar(range(200)): + time.sleep(0.02) + + +@example +def eta_on_generators(): + def gen(): + for _ in range(200): + yield None - for n in pbar(iter(range(100)), 100): - # iter range is a way to get an iterator in both python 2 and 3 - pass + widgets = [ + progressbar.AdaptiveETA(), + ' ', + progressbar.ETA(), + ' ', + progressbar.Timer(), + ] -if __name__ == '__main__': - try: + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(gen()): + time.sleep(0.02) + + +@example +def percentage_on_generators(): + def gen(): + for _ in range(200): + yield None + + widgets = [ + progressbar.Counter(), + ' ', + progressbar.Percentage(), + ' ', + progressbar.SimpleProgress(), + ' ', + ] + + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(gen()): + time.sleep(0.02) + + +def test(*tests) -> None: + if tests: + no_tests = True + for example in examples: + for test in tests: + if test in example.__name__: + example() + no_tests = False + break + + if no_tests: + for example in examples: + print('Skipping', example.__name__) + else: for example in examples: example() + + +if __name__ == '__main__': + try: + test(*sys.argv[1:]) except KeyboardInterrupt: - sys.stdout('\nQuitting examples.\n') + sys.stdout.write('\nQuitting examples.\n') diff --git a/progressbar/__about__.py b/progressbar/__about__.py new file mode 100644 index 00000000..785fff86 --- /dev/null +++ b/progressbar/__about__.py @@ -0,0 +1,27 @@ +"""Text progress bar library for Python. + +A text progress bar is typically used to display the progress of a long +running operation, providing a visual cue that processing is underway. + +The ProgressBar class manages the current progress, and the format of the line +is given by a number of widgets. A widget is an object that may display +differently depending on the state of the progress bar. + +The progressbar module is very easy to use, yet very powerful. It will also +automatically enable features like auto-resizing when the system supports it. +""" + +__title__ = 'Python Progressbar' +__package_name__ = 'progressbar2' +__author__ = 'Rick van Hattem (Wolph)' +__description__: str = ' '.join( + """ +A Python Progressbar library to provide visual (yet text based) progress to +long running operations. +""".strip().split(), +) +__email__ = 'wolph@wol.ph' +__version__ = '4.5.0' +__license__ = 'BSD' +__copyright__ = 'Copyright 2015 Rick van Hattem (Wolph)' +__url__ = 'https://github.com/WoLpH/python-progressbar' diff --git a/progressbar/__init__.py b/progressbar/__init__.py index 9e1b3b68..cf4de765 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -1,92 +1,91 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# -# progressbar - Text progress bar library for Python. -# Copyright (c) 2005 Nilton Volpato -# Copyright (c) 2012 Rick van Hattem -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - - -'''Text progress bar library for Python. - -A text progress bar is typically used to display the progress of a long -running operation, providing a visual cue that processing is underway. - -The ProgressBar class manages the current progress, and the format of the line -is given by a number of widgets. A widget is an object that may display -differently depending on the state of the progress bar. There are three types -of widgets: - - - a string, which always shows itself - - - a ProgressBarWidget, which may return a different value every time its - update method is called - - - a ProgressBarWidgetHFill, which is like ProgressBarWidget, except it - expands to fill the remaining width of the line. - -The progressbar module is very easy to use, yet very powerful. It will also -automatically enable features like auto-resizing when the system supports it. -''' from datetime import date -from progressbar.widgets import ( - Timer, +from .__about__ import __author__, __version__ +from .algorithms import ( + DoubleExponentialMovingAverage, + ExponentialMovingAverage, + SmoothingAlgorithm, +) +from .bar import DataTransferBar, NullBar, ProgressBar +from .base import UnknownLength +from .multi import MultiBar, SortKey +from .shortcuts import progressbar +from .terminal.stream import LineOffsetStreamWrapper +from .utils import len_color, streams +from .widgets import ( ETA, + AbsoluteETA, AdaptiveETA, - FileTransferSpeed, AdaptiveTransferSpeed, AnimatedMarker, + Bar, + BouncingBar, Counter, - Percentage, + CurrentTime, + DataSize, + DynamicMessage, + FileTransferSpeed, + FormatCustomText, FormatLabel, - SimpleProgress, - Bar, + FormatLabelBar, + GranularBar, + JobStatusBar, + MultiProgressBar, + MultiRangeBar, + Percentage, + PercentageLabelBar, ReverseBar, - BouncingBar, RotatingMarker, + SimpleProgress, + SmoothingETA, + Timer, + Variable, + VariableMixin, ) -from .bar import ProgressBar, UnknownLength - - -__author__ = 'Rick van Hattem' -__author_email__ = 'Rick.van.Hattem@Fawo.nl' __date__ = str(date.today()) -__version__ = '2.7.3' __all__ = [ - 'AbstractWidget', - 'Widget', - 'WidgetHFill', - 'Timer', 'ETA', + 'AbsoluteETA', 'AdaptiveETA', - 'FileTransferSpeed', 'AdaptiveTransferSpeed', 'AnimatedMarker', - 'Counter', - 'Percentage', - 'FormatLabel', - 'SimpleProgress', 'Bar', - 'ReverseBar', 'BouncingBar', - 'UnknownLength', + 'Counter', + 'CurrentTime', + 'DataSize', + 'DataTransferBar', + 'DoubleExponentialMovingAverage', + 'DynamicMessage', + 'ExponentialMovingAverage', + 'FileTransferSpeed', + 'FormatCustomText', + 'FormatLabel', + 'FormatLabelBar', + 'GranularBar', + 'JobStatusBar', + 'LineOffsetStreamWrapper', + 'MultiBar', + 'MultiProgressBar', + 'MultiRangeBar', + 'NullBar', + 'Percentage', + 'PercentageLabelBar', 'ProgressBar', - 'format_updatable', + '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..59e4117c --- /dev/null +++ b/progressbar/__main__.py @@ -0,0 +1,423 @@ +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. + """ + + 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. + """ + parser = argparse.ArgumentParser(description=description) + + # 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 if filesize_available else 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, BrokenPipeError): + for input_path in input_paths: + if isinstance(input_path, pathlib.Path): + if args.line_mode: + # newline='' disables universal-newline + # translation so the byte count matches the file + # size for CRLF files as well + input_stream = stack.enter_context( + input_path.open('r', newline=''), + ) + else: + input_stream = stack.enter_context( + input_path.open('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) + if isinstance(data, str): + # The total size is measured in bytes, so progress + # must be tracked in bytes as well + encoding = ( + getattr(input_stream, 'encoding', None) or 'utf-8' + ) + total_transferred += len( + data.encode(encoding, errors='replace'), + ) + else: + 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 != '-': + if line_mode: + # newline='' passes the data through without newline + # translation, mirroring the input handling + return stack.enter_context( + open(output, 'w', newline=''), # noqa: SIM115 + ) + + return stack.enter_context(open(output, 'wb')) # 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..8dd2cf89 --- /dev/null +++ b/progressbar/algorithms.py @@ -0,0 +1,62 @@ +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: float | None = None + + def update(self, new_value: float, elapsed: timedelta) -> float: + if self.value is None: + # Seed with the first observation instead of biasing towards 0 + self.value = new_value + else: + 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: float | None = None + self.ema2: float | None = None + + def update(self, new_value: float, elapsed: timedelta) -> float: + if self.ema1 is None or self.ema2 is None: + # Seed with the first observation instead of biasing towards 0 + self.ema1 = self.ema2 = new_value + else: + 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 ed2ed6c2..03529017 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -1,154 +1,598 @@ -from __future__ import division, absolute_import, with_statement +from __future__ import annotations + +import abc +import contextlib +import itertools +import logging +import math import os import sys -import math -import fcntl -import termios -import array -import signal +import time +import timeit +import typing import warnings -from datetime import datetime, timedelta -import collections -from . import widgets -from . import six -from . import utils +import weakref +from copy import deepcopy +from datetime import datetime +from types import FrameType + +from python_utils import converters, types + +import progressbar.env +import progressbar.terminal +import progressbar.terminal.stream + +from . import ( + base, + utils, + widgets, + widgets as widgets_module, # Avoid name collision +) +from .terminal import os_specific + +logger = logging.getLogger(__name__) + +# float also accepts integers and longs but we don't want an explicit union +# due to type checking complexity +NumberT = float +ValueT = typing.Union[NumberT, type[base.UnknownLength], None] + +T = types.TypeVar('T') + + +class ProgressBarMixinBase(abc.ABC): + _started = False + _finished = False + _last_update_time: types.Optional[float] = None + + #: The terminal width. This should be automatically detected but will + #: fall back to 80 if auto detection is not possible. + term_width: int = 80 + #: The widgets to render, defaults to the result of `default_widget()` + widgets: types.MutableSequence[widgets_module.WidgetBase | str] + #: When going beyond the max_value, raise an error if True or silently + #: ignore otherwise + max_error: bool + #: Prefix the progressbar with the given string + prefix: types.Optional[str] + #: Suffix the progressbar with the given string + suffix: types.Optional[str] + #: Justify to the left if `True` or the right if `False` + left_justify: bool + #: The default keyword arguments for the `default_widgets` if no widgets + #: are configured + widget_kwargs: types.Dict[str, types.Any] + #: Custom length function for multibyte characters such as CJK + # mypy and pyright can't agree on what the correct one is... so we'll + # need to use a helper function :( + # custom_len: types.Callable[['ProgressBarMixinBase', str], int] + custom_len: types.Callable[[str], int] + #: The time the progress bar was started + initial_start_time: types.Optional[datetime] + #: The interval to poll for updates in seconds if there are updates + poll_interval: types.Optional[float] + #: The minimum interval to poll for updates in seconds even if there are + #: no updates + min_poll_interval: float + + #: Deprecated: The number of intervals that can fit on the screen with a + #: minimum of 100 + num_intervals: int = 0 + #: Deprecated: The `next_update` is kept for compatibility with external + #: libs: https://github.com/WoLpH/python-progressbar/issues/207 + next_update: int = 0 + + #: Current progress (min_value <= value <= max_value) + value: NumberT + #: Previous progress value + previous_value: types.Optional[NumberT] + #: The minimum/start value for the progress bar + min_value: NumberT + #: Maximum (and final) value. Beyond this value an error will be raised + #: unless the `max_error` parameter is `False`. + max_value: ValueT + #: The time the progressbar reached `max_value` or when `finish()` was + #: called. + end_time: types.Optional[datetime] + #: The time `start()` was called or iteration started. + start_time: types.Optional[datetime] + #: Seconds between `start_time` and last call to `update()` + seconds_elapsed: float + + #: Extra data for widgets with persistent state. This is used by + #: sampling widgets for example. Since widgets can be shared between + #: multiple progressbars we need to store the state with the progressbar. + extra: types.Dict[str, types.Any] + + def get_last_update_time(self) -> types.Optional[datetime]: + if self._last_update_time: + return datetime.fromtimestamp(self._last_update_time) + else: + return None + def set_last_update_time(self, value: types.Optional[datetime]): + if value: + self._last_update_time = time.mktime(value.timetuple()) + else: + self._last_update_time = None -class FalseMeta(type): - def __bool__(self): - return False + last_update_time = property(get_last_update_time, set_last_update_time) - def __cmp__(self, other): - return -1 + def __init__(self, **kwargs: typing.Any): # noqa: B027 + pass - __nonzero__ = __bool__ + def start(self, **kwargs: typing.Any): + self._started = True + def update(self, value: ValueT = None): # noqa: B027 + pass -class UnknownLength(object): - __metaclass__ = FalseMeta + def finish(self): # pragma: no cover + self._finished = True + + def __del__(self): + if not self._finished and self._started: # pragma: no cover + # We're not using contextlib.suppress here because during teardown + # contextlib is not available anymore. Any exception can occur + # here during interpreter shutdown (closed streams, partially + # torn down modules), so we suppress all of them. + try: # noqa: SIM105 + self.finish() + except Exception: # noqa: BLE001, S110 + pass + def __getstate__(self): + return self.__dict__ -class ProgressBarMixinBase(object): - def __init__(self, **kwargs): - pass + def data(self) -> types.Dict[str, types.Any]: # pragma: no cover + raise NotImplementedError() - def start(self): - pass + def started(self) -> bool: + return self._finished or self._started - def update(self, value=None): - pass + def finished(self) -> bool: + return self._finished - def finish(self): # pragma: no cover - pass +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) -class ProgressBarBase(collections.Iterable, ProgressBarMixinBase): - pass + def __repr__(self): + label = f': {self.label}' if self.label else '' + return f'<{self.__class__.__name__}#{self.index}{label}>' class DefaultFdMixin(ProgressBarMixinBase): - def __init__(self, fd=sys.stderr, **kwargs): + # 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 - super(DefaultFdMixin, self).__init__(**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. + + 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}') - def update(self, *args, **kwargs): - super(DefaultFdMixin, self).update(*args, **kwargs) - self.fd.write('\r' + self._format_line()) + return color_support - def finish(self, *args, **kwargs): # pragma: no cover - super(DefaultFdMixin, self).finish(*args, **kwargs) - self.fd.write('\n') + 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() -class ResizableMixin(DefaultFdMixin): - _DEFAULT_TERMSIZE = 80 + def update(self, *args: types.Any, **kwargs: types.Any) -> None: + ProgressBarMixinBase.update(self, *args, **kwargs) - def __init__(self, term_width=_DEFAULT_TERMSIZE, **kwargs): - super(ResizableMixin, self).__init__(**kwargs) + line: str = converters.to_unicode(self._format_line()) + if not self.enable_colors: + line = utils.no_color(line) - self.signal_set = False - if term_width is not None: - self.term_width = term_width + line = line.rstrip() + '\n' if self.line_breaks else '\r' + line + + try: # pragma: no cover + self.fd.write(line) + except UnicodeEncodeError: # pragma: no cover + self.fd.write(types.cast(str, line.encode('ascii', 'replace'))) + + def finish( + self, + *args: types.Any, + **kwargs: types.Any, + ) -> None: # pragma: no cover + os_specific.reset_console_mode() + + if self._finished: + return + + end = kwargs.pop('end', '\n') + ProgressBarMixinBase.finish(self, *args, **kwargs) + + if end and not self.line_breaks: + self.fd.write(end) + + self.fd.flush() + + def _format_line(self): + "Joins the widgets and justifies the line." + widgets = ''.join(self._to_unicode(self._format_widgets())) + + if self.left_justify: + return widgets.ljust(self.term_width) else: - try: - self._handle_resize() - signal.signal(signal.SIGWINCH, self._handle_resize) - self.signal_set = True - except (SystemExit, KeyboardInterrupt): # pragma: no cover - raise - except: # pragma: no cover - raise - self.term_width = self._env_size() + return widgets.rjust(self.term_width) + + def _format_widgets(self): + result = [] + expanding = [] + width = self.term_width + data = self.data() + + for index, widget in enumerate(self.widgets): + if isinstance( + widget, + widgets.WidgetBase, + ) and not widget.check_size(self): + continue + elif isinstance(widget, widgets.AutoWidthWidgetBase): + result.append(widget) + expanding.insert(0, index) + elif isinstance(widget, str): + result.append(widget) + width -= self.custom_len(widget) # type: ignore + else: + widget_output = converters.to_unicode(widget(self, data)) + result.append(widget_output) + width -= self.custom_len(widget_output) # type: ignore + + count = len(expanding) + while expanding: + portion = max(math.ceil(width / 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 _ResizeRegistry: + """ + Shared SIGWINCH handling for all resizable progressbars. + + A single signal handler dispatches to every live bar. The original + handler is saved when the first bar registers and restored when the + last one unregisters, so overlapping bars can finish in any order + without leaving a dangling handler installed. + """ + + bars: typing.ClassVar[weakref.WeakSet[ResizableMixin]] = weakref.WeakSet() + previous_handler: typing.ClassVar[typing.Any] = None + + @classmethod + def install(cls, bar: ResizableMixin) -> None: + import signal + + if not hasattr(signal, 'SIGWINCH'): # pragma: no cover + # Not available on Windows + return + + if not cls.bars: + cls.previous_handler = signal.getsignal( + signal.SIGWINCH # type: ignore[attr-defined] + ) + signal.signal( + signal.SIGWINCH, # type: ignore[attr-defined] + cls.handle_resize, + ) + + cls.bars.add(bar) + + @classmethod + def uninstall(cls, bar: ResizableMixin) -> None: + import signal + + if not hasattr(signal, 'SIGWINCH'): # pragma: no cover + # Not available on Windows + return + + cls.bars.discard(bar) + if not cls.bars: + signal.signal( + signal.SIGWINCH, # type: ignore[attr-defined] + cls.previous_handler, + ) + cls.previous_handler = None + + @classmethod + def handle_resize( + cls, signum: int | None = None, frame: None | FrameType = None + ) -> None: + for bar in list(cls.bars): + bar._handle_resize(signum, frame) - def _env_size(self): - 'Tries to find the term_width from the environment.' - return int(os.environ.get('COLUMNS', self._DEFAULT_TERMSIZE)) - 1 +class ResizableMixin(ProgressBarMixinBase): + def __init__(self, term_width: int | None = None, **kwargs: typing.Any): + ProgressBarMixinBase.__init__(self, **kwargs) - def _handle_resize(self, signum=None, frame=None): - 'Tries to catch resize signals sent from the terminal.' + self.signal_set = False + if term_width: + self.term_width = term_width + else: # pragma: no cover + with contextlib.suppress(Exception): + self._handle_resize() + _ResizeRegistry.install(self) + self.signal_set = True - size = fcntl.ioctl(self.fd, termios.TIOCGWINSZ, '\0' * 8) - h, w = array.array('h', size)[:2] + 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: - signal.signal(signal.SIGWINCH, signal.SIG_DFL) + with contextlib.suppress(Exception): + _ResizeRegistry.uninstall(self) + self.signal_set = False class StdRedirectMixin(DefaultFdMixin): - def __init__(self, redirect_stderr=False, redirect_stdout=False, **kwargs): - super(StdRedirectMixin, self).__init__(**kwargs) + """Redirect ``stdout``/``stderr`` so prints appear above the bar. + + Args: + redirect_stderr (bool): Capture ``sys.stderr`` and print it above the + bar instead of letting it corrupt the bar. + redirect_stdout (bool): Capture ``sys.stdout`` and print it above the + bar instead of letting it corrupt the bar. + redirect_blank_line (bool): When redirecting, keep a blank line + between the redirected output and the bar. Defaults to ``False``. + """ + + redirect_stderr: bool = False + redirect_stdout: bool = False + redirect_blank_line: 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, + redirect_blank_line: bool = False, + **kwargs, + ): + DefaultFdMixin.__init__(self, **kwargs) self.redirect_stderr = redirect_stderr self.redirect_stdout = redirect_stdout + # Separate redirected output from the bar with a blank line + self.redirect_blank_line = redirect_blank_line + self._stdout = self.stdout = sys.stdout + self._stderr = self.stderr = sys.stderr + + def start(self, *args: typing.Any, **kwargs: typing.Any): + if self.redirect_stdout: + utils.streams.wrap_stdout() if self.redirect_stderr: - self._stderr = sys.stderr - sys.stderr = six.StringIO() + utils.streams.wrap_stderr() - if self.redirect_stdout: - self._stdout = sys.stdout - sys.stdout = six.StringIO() + self._stdout = utils.streams.original_stdout + self._stderr = utils.streams.original_stderr - def update(self, value=None): - super(StdRedirectMixin, self).update(value=value) + self.stdout = utils.streams.stdout + self.stderr = utils.streams.stderr - if self.redirect_stderr and sys.stderr.tell(): - self.fd.write('\r' + ' ' * self.term_width + '\r') - self._stderr.write(sys.stderr.getvalue()) - self._stderr.flush() - sys.stderr = six.StringIO() + utils.streams.start_capturing(self) + DefaultFdMixin.start(self, *args, **kwargs) - if self.redirect_stdout and sys.stdout.tell(): + def update(self, value: types.Optional[NumberT] = None): + cleared = not self.line_breaks and utils.streams.needs_clear() + if cleared: self.fd.write('\r' + ' ' * self.term_width + '\r') - self._stdout.write(sys.stdout.getvalue()) - self._stdout.flush() - sys.stdout = six.StringIO() - - def finish(self): - super(StdRedirectMixin, self).finish() - if self.redirect_stderr: - self._stderr.write(sys.stderr.getvalue()) - sys.stderr = self._stderr + utils.streams.flush() + if cleared and self.redirect_blank_line: + # Keep a blank line between the redirected output and the bar + self.fd.write('\n') + DefaultFdMixin.update(self, value=value) + def finish(self, end='\n'): + DefaultFdMixin.finish(self, end=end) + utils.streams.stop_capturing(self) if self.redirect_stdout: - self._stdout.write(sys.stdout.getvalue()) - sys.stdout = self._stdout + utils.streams.unwrap_stdout() - -class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase): - - '''The ProgressBar class which updates and prints the bar. + if self.redirect_stderr: + utils.streams.unwrap_stderr() + + +class ProgressBar( + StdRedirectMixin, + ResizableMixin, + ProgressBarBase, +): + """The ProgressBar class which updates and prints the bar. + + Args: + min_value (int): The minimum/start value for the progress bar + max_value (int): The maximum/end value for the progress bar. + Defaults to `_DEFAULT_MAXVAL` + widgets (list): The widgets to render, defaults to the result of + `default_widget()` + left_justify (bool): Justify to the left if `True` or the right if + `False` + initial_value (int): The value to start with + poll_interval (float): The update interval in seconds. + Note that if your widgets include timers or animations, the actual + interval may be smaller (faster updates). Also note that updates + never happens faster than `min_poll_interval` which can be used for + reduced output in logs + min_poll_interval (float): The minimum update interval in seconds. + The bar will _not_ be updated faster than this, despite changes in + the progress, unless `force=True`. This is limited to be at least + `_MINIMUM_UPDATE_INTERVAL`. If available, it is also bound by the + environment variable PROGRESSBAR_MINIMUM_UPDATE_INTERVAL + widget_kwargs (dict): The default keyword arguments for widgets + custom_len (function): Method to override how the line width is + calculated. When using non-latin characters the width + calculation might be off by default + max_error (bool): When True the progressbar will raise an error if it + goes beyond it's set max_value. Otherwise the max_value is simply + raised when needed + prefix (str): Prefix the progressbar with the given string + suffix (str): Prefix the progressbar with the given string + variables (dict): User-defined variables variables that can be used + from a label using `format='{variables.my_var}'`. These values can + be updated using `bar.update(my_var='newValue')` This can also be + used to set initial values for variables' widgets + line_offset (int): The number of lines to offset the progressbar from + your current line. This is useful if you have other output or + multiple progressbars A common way of using it is like: >>> progress = ProgressBar().start() >>> for i in range(100): - ... progress.update(i+1) + ... progress.update(i + 1) ... # do something - ... >>> progress.finish() You can also use a ProgressBar as an iterator: @@ -158,7 +602,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 @@ -175,66 +618,146 @@ 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 - - finished: True if the bar has finished (reached 100%) - - start_time: the time when start() method of ProgressBar was called - - seconds_elapsed: seconds elapsed since start_time and last call to - update - ''' - - _DEFAULT_MAXVAL = 100 - - def __init__(self, min_value=0, max_value=None, widgets=None, - left_justify=True, initial_value=0, poll_interval=None, - **kwargs): - '''Initializes a progress bar with sane defaults''' - super(ProgressBar, self).__init__(**kwargs) - if not max_value and kwargs.get('max_value'): - warnings.warn('The usage of `max_value` is deprecated, please use ' - '`max_value` instead', DeprecationWarning) - max_value = kwargs.get('max_value') + """ + + _iterable: types.Optional[types.Iterator] + + _DEFAULT_MAXVAL: type[base.UnknownLength] = base.UnknownLength + # update every 50 milliseconds (up to a 20 times per second) + _MINIMUM_UPDATE_INTERVAL: float = 0.050 + _last_update_time: types.Optional[float] = None + paused: bool = False + + def __init__( + self, + min_value: NumberT = 0, + max_value: ValueT = None, + widgets: types.Optional[ + types.Sequence[widgets_module.WidgetBase | str] + ] = None, + left_justify: bool = True, + initial_value: NumberT = 0, + poll_interval: types.Optional[float] = None, + widget_kwargs: types.Optional[types.Dict[str, types.Any]] = None, + custom_len: types.Callable[[str], int] = utils.len_color, + max_error=True, + prefix=None, + suffix=None, + variables=None, + min_poll_interval=None, + **kwargs, + ): # sourcery skip: low-code-quality + """Initializes a progress bar with sane defaults.""" + StdRedirectMixin.__init__(self, **kwargs) + ResizableMixin.__init__(self, **kwargs) + ProgressBarBase.__init__(self, **kwargs) + if not max_value and kwargs.get('maxval') is not None: + warnings.warn( + 'The usage of `maxval` is deprecated, please use ' + '`max_value` instead', + DeprecationWarning, + stacklevel=1, + ) + max_value = kwargs.get('maxval') if not poll_interval and kwargs.get('poll'): - warnings.warn('The usage of `poll` is deprecated, please use ' - '`poll_interval` instead', DeprecationWarning) + 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 or self.default_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) - - self.poll_interval = poll_interval - for widget in self.widgets: - interval = getattr(widget, 'INTERVAL', None) - if interval is not None: - self.poll_interval = min( - self.poll_interval or interval, - interval, - ) + self._last_update_timer = timeit.default_timer() + self._started = False + self._finished = False @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 @@ -263,37 +786,52 @@ def percentage(self): 25.0 >>> progress.max_value = None >>> progress.percentage - ''' - if self.max_value is None: + """ + 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 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 - ''' - self.last_update_time = datetime.now() - elapsed = self.last_update_time - self.start_time + 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 days since the bar started + - `time_elapsed`: The raw elapsed `datetime.timedelta` object + - `percentage`: Percentage as a float or `None` if no max_value + is available + - `dynamic_messages`: Deprecated, use `variables` instead. + - `variables`: Dictionary of user-defined variables for the + :py:class:`~progressbar.widgets.Variable`'s. + + """ + self._last_update_time = time.time() + self._last_update_timer = timeit.default_timer() + elapsed = self.last_update_time - self.start_time # type: ignore # For Python 2.7 and higher we have _`timedelta.total_seconds`, but we # want to support older versions as well - total_seconds_elapsed = utils.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, @@ -312,192 +850,386 @@ 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, + seconds_elapsed=(elapsed.seconds % 60) + + (elapsed.microseconds / 1000000.0), # The minutes since the bar started modulo 60 minutes_elapsed=(elapsed.seconds / 60) % 60, # The hours since the bar started modulo 24 hours_elapsed=(elapsed.seconds / (60 * 60)) % 24, - # The hours since the bar started - days_elapsed=(elapsed.seconds / (60 * 60 * 24)), + # The days since the bar started + days_elapsed=(elapsed.total_seconds() / (60 * 60 * 24)), # The raw elapsed `datetime.timedelta` object time_elapsed=elapsed, # Percentage as a float or `None` if no max_value is available percentage=self.percentage, + # Dictionary of user-defined + # :py:class:`progressbar.widgets.Variable`'s + variables=self.variables, + # Deprecated alias for `variables` + dynamic_messages=self.variables, ) def default_widgets(self): if self.max_value: return [ - widgets.Percentage(), - ' (', widgets.SimpleProgress(), ')', - ' ', widgets.Bar(), - ' ', widgets.Timer(), - ' ', widgets.AdaptiveETA(), + widgets.Percentage(**self.widget_kwargs), + ' ', + widgets.SimpleProgress( + format=f'({widgets.SimpleProgress.DEFAULT_FORMAT})', + **self.widget_kwargs, + ), + ' ', + widgets.Bar(**self.widget_kwargs), + ' ', + widgets.Timer(**self.widget_kwargs), + ' ', + widgets.SmoothingETA(**self.widget_kwargs), ] else: return [ - widgets.Percentage(), - ' (', widgets.SimpleProgress(), ')', - ' ', widgets.Bar(), - ' ', widgets.Timer(), + widgets.AnimatedMarker(**self.widget_kwargs), + ' ', + widgets.BouncingBar(**self.widget_kwargs), + ' ', + widgets.Counter(**self.widget_kwargs), + ' ', + widgets.Timer(**self.widget_kwargs), ] def __call__(self, iterable, max_value=None): - 'Use a ProgressBar to iterate through an iterable' - if max_value is 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: - if self.max_value is None: - self.max_value = UnknownLength - else: - self.max_value = max_value + except TypeError: # pragma: no cover + self.max_value = base.UnknownLength self._iterable = iter(iterable) return self def __iter__(self): - return self + # A generator (rather than returning ``self``) so that abandoning the + # loop early - a `break` or an exception in the loop body - triggers + # `GeneratorExit` on garbage collection, letting us finish the bar and + # restore any redirected streams. See issue #212. + try: + while True: + try: + value = next(self) + except StopIteration: + return + yield value + except GeneratorExit: + self.finish(dirty=True) + raise 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 + 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, basestring): - result.append(widget) - width -= len(widget) - else: - widget_output = 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 + "Updates the ProgressBar by adding a new value." + return self.increment(value) - return result - - def _format_line(self): - 'Joins the widgets and justifies the line' - - widgets = ''.join(self._format_widgets()) - - if self.left_justify: - return widgets.ljust(self.term_width) - else: - return widgets.rjust(self.term_width) + def 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.value > self.next_update or self.end_time: + "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 + elif self.max_value is base.UnknownLength: + # There's no terminal-width threshold to compute for an unknown + # length, so redraw whenever the value advanced (still rate + # limited by the min_poll_interval check above) + return self.value != self.previous_value + + # Update if value increment is not large enough to + # add more bars to progressbar (according to current + # terminal width) + with contextlib.suppress(Exception): + divisor: float = self.max_value / self.term_width # type: ignore + value_divisor = self.value // divisor # type: ignore + pvalue_divisor = self.previous_value // divisor # type: ignore + if value_divisor != pvalue_divisor: + return True + # No need to redraw yet + return False - elif self.poll_interval: - delta = datetime.now() - self.last_update_time - return delta > self.poll_interval - - def update(self, value=None): - 'Updates the ProgressBar to a new value.' + 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) - if value is not None and value is not UnknownLength: - if self.max_value is 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 out of range, should be between %s and %s' - % (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 - if not self._needs_update(): - return + # Save the updated values for dynamic messages + variables_changed = self._update_variables(kwargs) - self.updates += 1 - super(ProgressBar, self).update(value=value) + if self._needs_update() or variables_changed or force: + self._update_parents(value) + + def _update_variables(self, kwargs): + variables_changed = False + for key, value_ in kwargs.items(): + if key not in self.variables: + raise TypeError( + 'update() got an unexpected variable name as argument ' + f'{key!r}', + ) + elif self.variables[key] != value_: + self.variables[key] = kwargs[key] + variables_changed = True + return variables_changed - def start(self): - '''Starts measuring time, and prints the bar at 0%. + def _update_parents(self, value: ValueT): + self.updates += 1 + ResizableMixin.update(self, value=value) + ProgressBarBase.update(self, value=value) + StdRedirectMixin.update(self, value=value) # type: ignore + + # Only flush if something was actually written + self.fd.flush() + + def start( + self, + max_value: NumberT | None = None, + init: bool = True, + *args: typing.Any, + **kwargs: typing.Any, + ) -> ProgressBar: + """Starts measuring time, and prints the bar at 0%. It returns self so you can use it like this: + Args: + max_value (int): The maximum value of the progressbar + init (bool): (Re)Initialize the progressbar, this is useful if you + wish to reuse the same progressbar but can be disabled if + data needs to be persisted between runs + >>> pbar = ProgressBar().start() >>> for i in range(100): - ... # do something - ... pbar.update(i+1) - ... + ... # do something + ... pbar.update(i + 1) >>> pbar.finish() - ''' - super(ProgressBar, self).start() + """ + if init: + self.init() + + # Prevent multiple starts + if self.start_time is not None: # pragma: no cover + return self + + if max_value is not None: + self.max_value = max_value if self.max_value is None: self.max_value = self._DEFAULT_MAXVAL - self.num_intervals = max(100, self.term_width) - self.next_update = 0 + StdRedirectMixin.start(self, max_value=max_value) + ResizableMixin.start(self, max_value=max_value) + ProgressBarBase.start(self, max_value=max_value) + + # Constructing the default widgets is only done when we know max_value + if not self.widgets: + self.widgets = self.default_widgets() - if self.max_value is not UnknownLength: - if self.max_value < 0: - raise ValueError('Value out of range') - self.update_interval = self.max_value / self.num_intervals + self._init_prefix() + self._init_suffix() + self._calculate_poll_interval() + self._verify_max_value() - self.start_time = self.last_update_time = datetime.now() - self.update(0) + 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 finish(self): - 'Puts the ProgressBar bar in the finished state.' + def _init_suffix(self): + if self.suffix: + self.widgets.append( + widgets.FormatLabel(self.suffix, new_style=True), + ) + # Unset the suffix variable after applying so an extra start() + # won't keep copying it + self.suffix = None + + def _init_prefix(self): + if self.prefix: + self.widgets.insert( + 0, + widgets.FormatLabel(self.prefix, new_style=True), + ) + # Unset the prefix variable after applying so an extra start() + # won't keep copying it + self.prefix = None + + def _verify_max_value(self): + if ( + self.max_value is not base.UnknownLength + and self.max_value is not None + and self.max_value < 0 # type: ignore + ): + raise ValueError(f'max_value out of range, got {self.max_value!r}') + + def _calculate_poll_interval(self) -> None: + self.num_intervals = max(100, self.term_width) + for widget in self.widgets: + interval: int | float | None = utils.deltas_to_seconds( + getattr(widget, 'INTERVAL', None), + default=None, + ) + if interval is not None: + self.poll_interval = min( + self.poll_interval or interval, + interval, + ) + + def finish(self, end: str = '\n', dirty: bool = False): + """ + Puts the ProgressBar bar in the finished state. + + Also flushes and disables output buffering if this was the last + progressbar running. + + Args: + end (str): The string to end the progressbar with, defaults to a + newline + dirty (bool): When True the progressbar kept the current state and + won't be set to 100 percent + """ + if self._finished: + # Finishing twice would corrupt the global stream-wrapping + # state, so extra calls are no-ops + return + + if not dirty: + self.end_time = datetime.now() + self.update(self.max_value, force=True) - self.end_time = datetime.now() - self.update(self.max_value) + 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 - super(ProgressBar, self).finish() +class DataTransferBar(ProgressBar): + """A progress bar with sensible defaults for downloads etc. + + This assumes that the values its given are numbers of bytes. + """ + + def default_widgets(self): + if self.max_value: + return [ + widgets.Percentage(), + ' of ', + widgets.DataSize('max_value'), + ' ', + widgets.Bar(), + ' ', + widgets.Timer(), + ' ', + widgets.SmoothingETA(), + ] + else: + return [ + widgets.AnimatedMarker(), + ' ', + widgets.DataSize(), + ' ', + widgets.Timer(), + ] + + +class NullBar(ProgressBar): + """ + Progress bar that does absolutely nothing. Useful for single verbosity + flags. + """ + + def start(self, *args: typing.Any, **kwargs: typing.Any): + return self + + def update(self, *args: typing.Any, **kwargs: typing.Any): + return self + + def finish(self, *args: typing.Any, **kwargs: typing.Any): + return self diff --git a/progressbar/base.py b/progressbar/base.py new file mode 100644 index 00000000..6c80c19d --- /dev/null +++ b/progressbar/base.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import IO, TextIO + + +class FalseMeta(type): + @classmethod + def __bool__(cls) -> bool: # pragma: no cover + return False + + +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..14d92dfc --- /dev/null +++ b/progressbar/env.py @@ -0,0 +1,194 @@ +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) + elif env_flag(variable, default=False): + # Generic truthy flags such as `FORCE_COLOR=1` enable + # color support but don't specify the depth; assume full + # color support analogous to the Jupyter handling above. + return cls.XTERM_TRUECOLOR + + 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/metadata.py b/progressbar/metadata.py deleted file mode 100644 index 710239f9..00000000 --- a/progressbar/metadata.py +++ /dev/null @@ -1,30 +0,0 @@ -'''Text progress bar library for Python. - -A text progress bar is typically used to display the progress of a long -running operation, providing a visual cue that processing is underway. - -The ProgressBar class manages the current progress, and the format of the line -is given by a number of widgets. A widget is an object that may display -differently depending on the state of the progress bar. There are three types -of widgets: - - - a string, which always shows itself - - - a ProgressBarWidget, which may return a different value every time its - update method is called - - - a ProgressBarWidgetHFill, which is like ProgressBarWidget, except it - expands to fill the remaining width of the line. - -The progressbar module is very easy to use, yet very powerful. It will also -automatically enable features like auto-resizing when the system supports it. -''' - -import datetime - -__package_name__ = 'progressbar2' -__author__ = 'Rick van Hattem' -__author_email__ = 'Wolph@Wol.ph' -__url__ = 'https://github.com/WoLpH/python-progressbar' -__date__ = str(datetime.date.today()) -__version__ = '3.0.0' diff --git a/progressbar/multi.py b/progressbar/multi.py new file mode 100644 index 00000000..fabd1b2d --- /dev/null +++ b/progressbar/multi.py @@ -0,0 +1,410 @@ +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 + _thread: threading.Thread | None + _thread_finished: threading.Event + _thread_closed: 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() + self._print_lock = threading.RLock() + self._thread = None + self._thread_finished = threading.Event() + self._thread_closed = threading.Event() + + 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: # 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: + # The fd write happens under the lock as well so concurrent + # print()/render() calls cannot interleave their output + with self._print_lock: + value = self._buffer.getvalue() + self._buffer.seek(0) + self._buffer.truncate(0) + self.fd.write(value) + 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 list(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_finished.clear() + self._thread_closed.clear() + self._thread = threading.Thread( + target=self.run, + args=(False,), + daemon=True, + ) + 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) + if not self._thread.is_alive(): + self._thread = None + + def stop(self, timeout: float | None = None): + self._thread_finished.set() + self.join(timeout=timeout) + + def get_sorted_bars(self): + # Materialize the values into a list first so other threads can + # add or remove bars while we are sorting and rendering + bars = list(self.values()) + return sorted(bars, 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: + if exc_type is None: + self.join() + else: + # Don't wait for unfinished progressbars when an exception is + # propagating; that would block forever + self.stop() 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 6f7de0d5..00000000 --- a/progressbar/six.py +++ /dev/null @@ -1,14 +0,0 @@ -'''Library to make differences between Python 2 and 3 transparent''' - -__all__ = [ - 'StringIO', -] - -try: - from cStringIO import StringIO -except ImportError: # pragma: no cover - try: - from StringIO import StringIO - except ImportError: - from io import StringIO - 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..2d299f4e --- /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.saturation + (end.saturation - self.saturation) * step, + self.lightness + (end.lightness - self.lightness) * 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..ee873dcb --- /dev/null +++ b/progressbar/terminal/os_specific/posix.py @@ -0,0 +1,19 @@ +import sys +import termios +import tty + + +def getch() -> str: + if not sys.stdin.isatty(): + # Raw mode is unavailable (and unnecessary) without a tty + return sys.stdin.read(1) + + 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..9afd031c --- /dev/null +++ b/progressbar/terminal/os_specific/windows.py @@ -0,0 +1,229 @@ +# 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) +# GetStdHandle returns INVALID_HANDLE_VALUE (-1) when no console is +# attached (piped output, pythonw, services) +_INVALID_HANDLE_VALUE = _HANDLE(-1).value +# The EventType of a KEY_EVENT_RECORD in an INPUT_RECORD +_KEY_EVENT = 0x0001 + + +def _valid_handle(handle) -> bool: + # Handles may be plain ints (from a HANDLE restype) or ctypes + # instances; normalize before comparing + value = getattr(handle, 'value', handle) + return value is not None and value != _INVALID_HANDLE_VALUE + + +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})' + + +# Explicit argtypes are required: without them ctypes passes arguments +# as 32-bit C ints, silently truncating 64-bit HANDLE values +_GetConsoleMode = _kernel32.GetConsoleMode +_GetConsoleMode.argtypes = (_HANDLE, ctypes.POINTER(_DWORD)) +_GetConsoleMode.restype = _BOOL + +_SetConsoleMode = _kernel32.SetConsoleMode +_SetConsoleMode.argtypes = (_HANDLE, _DWORD) +_SetConsoleMode.restype = _BOOL + +_GetStdHandle = _kernel32.GetStdHandle +_GetStdHandle.argtypes = (_DWORD,) +_GetStdHandle.restype = _HANDLE + +_SetConsoleTextAttribute = _kernel32.SetConsoleTextAttribute +_SetConsoleTextAttribute.argtypes = (_HANDLE, _WORD) +_SetConsoleTextAttribute.restype = _BOOL + +_h_console_input = _GetStdHandle(_STD_INPUT_HANDLE) +_input_mode = _DWORD() +if _valid_handle(_h_console_input): + _GetConsoleMode(_HANDLE(_h_console_input), ctypes.byref(_input_mode)) + +_h_console_output = _GetStdHandle(_STD_OUTPUT_HANDLE) +_output_mode = _DWORD() +if _valid_handle(_h_console_output): + _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)) + + +_ReadConsoleInput = _kernel32.ReadConsoleInputA +_ReadConsoleInput.argtypes = ( + _HANDLE, + ctypes.POINTER(_INPUT_RECORD), + _DWORD, + ctypes.POINTER(_DWORD), +) +_ReadConsoleInput.restype = _BOOL + + +def reset_console_mode() -> None: + if _valid_handle(_h_console_input): + _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(_input_mode.value)) + + if _valid_handle(_h_console_output): + _SetConsoleMode(_HANDLE(_h_console_output), _DWORD(_output_mode.value)) + + +def set_console_mode() -> bool: + if not _valid_handle(_h_console_output): + return False + + if _valid_handle(_h_console_input): + mode = ( + _input_mode.value + | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_INPUT + ) + _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(mode)) + + mode = ( + _output_mode.value + | WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT + | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_PROCESSING + ) + return bool(_SetConsoleMode(_HANDLE(_h_console_output), _DWORD(mode))) + + +def get_console_mode() -> int: + return _input_mode.value + + +def set_text_color(color) -> None: + if _valid_handle(_h_console_output): + _SetConsoleTextAttribute(_HANDLE(_h_console_output), _WORD(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(): + if not _valid_handle(_h_console_input): + return None + + lp_buffer = (_INPUT_RECORD * 2)() + n_length = _DWORD(2) + lp_number_of_events_read = _DWORD() + + if not _ReadConsoleInput( + _HANDLE(_h_console_input), + lp_buffer, + n_length, + ctypes.byref(lp_number_of_events_read), + ): + return None + + # Only the records that were actually read contain valid data. The + # Event field is a union, so the KeyEvent member may only be read + # for KEY_EVENT records, and non-ASCII keys must not crash the + # decode. + for i in range(min(lp_number_of_events_read.value, len(lp_buffer))): + record = lp_buffer[i] + if record.EventType != _KEY_EVENT: + continue + + key_event = record.Event.KeyEvent + if not key_event.bKeyDown: + continue + + char = key_event.uChar.AsciiChar.decode('ascii', errors='replace') + if char != '\x00': + return char + + return None diff --git a/progressbar/terminal/stream.py b/progressbar/terminal/stream.py new file mode 100644 index 00000000..04429e47 --- /dev/null +++ b/progressbar/terminal/stream.py @@ -0,0 +1,145 @@ +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: + self.stream.flush() + + 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: + written = len(data) + 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 the length of the original data; callers use this to + # detect short writes + return written + + +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 f5131ef9..4a77da7a 100644 --- a/progressbar/utils.py +++ b/progressbar/utils.py @@ -1,4 +1,461 @@ +from __future__ import annotations -def timedelta_to_seconds(delta): - return (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * - 10 ** 6 / 10 ** 6) +import atexit +import contextlib +import datetime +import io +import logging +import os +import re +import sys +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() + + return ret + + def flush(self) -> None: + self.buffer.flush() + + def _flush(self) -> None: + if value := self.buffer.getvalue(): + self.flush() + # Clear the buffer before writing so a failed write cannot + # cause the same data to be written again by the next flush + self.buffer.seek(0) + self.buffer.truncate(0) + self.needs_clear = False + if not self.target.closed: + self.target.write(value) + + # when explicitly flushing, always flush the target as well + self.flush_target() + + def flush_target(self) -> None: # pragma: no cover + if not self.target.closed and getattr(self.target, 'flush', None): + self.target.flush() + + def __enter__(self) -> WrappingIO: + return self + + def fileno(self) -> int: + return self.target.fileno() + + def isatty(self) -> bool: + return self.target.isatty() + + def read(self, n: int = -1) -> str: + return self.target.read(n) + + def readable(self) -> bool: + return self.target.readable() + + def readline(self, limit: int = -1) -> str: + return self.target.readline(limit) + + def readlines(self, hint: int = -1) -> list[str]: + return self.target.readlines(hint) + + def seek(self, offset: int, whence: int = os.SEEK_SET) -> int: + return self.target.seek(offset, whence) + + def seekable(self) -> bool: + return self.target.seekable() + + def tell(self) -> int: + return self.target.tell() + + def truncate(self, size: types.Optional[int] = None) -> int: + return self.target.truncate(size) + + def writable(self) -> bool: + return self.target.writable() + + def writelines(self, lines: Iterable[str]) -> None: + return self.target.writelines(lines) + + def close(self) -> None: + self.flush() + self.target.close() + + def __next__(self) -> str: + return self.target.__next__() + + def __iter__(self) -> Iterator[str]: + return self.target.__iter__() + + def __exit__( + self, + __t: type[BaseException] | None, + __value: BaseException | None, + __traceback: TracebackType | None, + ) -> None: + self.close() + + +class StreamWrapper: + """Wrap stdout and stderr globally.""" + + stdout: base.TextIO | WrappingIO + stderr: base.TextIO | WrappingIO + original_excepthook: types.Callable[ + [ + types.Type[BaseException], + BaseException, + TracebackType | None, + ], + None, + ] + wrapped_stdout: int = 0 + wrapped_stderr: int = 0 + wrapped_excepthook: int = 0 + capturing: int = 0 + listeners: set + + def __init__(self) -> None: + self.stdout = self.original_stdout = sys.stdout + self.stderr = self.original_stderr = sys.stderr + self.original_excepthook = sys.excepthook + self.wrapped_stdout = 0 + self.wrapped_stderr = 0 + self.wrapped_excepthook = 0 + self.capturing = 0 + self.listeners = set() + + if env.env_flag('WRAP_STDOUT', default=False): # pragma: no cover + self.wrap_stdout() + + if env.env_flag('WRAP_STDERR', default=False): # pragma: no cover + self.wrap_stderr() + + def start_capturing(self, bar: ProgressBarMixinBase | None = None) -> None: + if bar: # pragma: no branch + self.listeners.add(bar) + + self.capturing += 1 + self.update_capturing() + + def stop_capturing(self, bar: ProgressBarMixinBase | None = None) -> None: + if bar: # pragma: no branch + with contextlib.suppress(KeyError): + self.listeners.remove(bar) + + self.capturing -= 1 + self.update_capturing() + + def update_capturing(self) -> None: # pragma: no cover + if isinstance(self.stdout, WrappingIO): + self.stdout.capturing = self.capturing > 0 + + if isinstance(self.stderr, WrappingIO): + self.stderr.capturing = self.capturing > 0 + + if self.capturing <= 0: + self.flush() + + def wrap(self, stdout: bool = False, stderr: bool = False) -> None: + if stdout: + self.wrap_stdout() + + if stderr: + self.wrap_stderr() + + def wrap_stdout(self) -> WrappingIO: + self.wrap_excepthook() + + if not self.wrapped_stdout: + self.stdout = sys.stdout = WrappingIO( # type: ignore + self.original_stdout, + listeners=self.listeners, + ) + self.wrapped_stdout += 1 + + return sys.stdout # type: ignore + + def wrap_stderr(self) -> WrappingIO: + self.wrap_excepthook() + + if not self.wrapped_stderr: + self.stderr = sys.stderr = WrappingIO( # type: ignore + self.original_stderr, + listeners=self.listeners, + ) + self.wrapped_stderr += 1 + + return sys.stderr # type: ignore + + def unwrap_excepthook(self) -> None: + if self.wrapped_excepthook: + self.wrapped_excepthook -= 1 + sys.excepthook = self.original_excepthook + + def wrap_excepthook(self) -> None: + if not self.wrapped_excepthook: + logger.debug('wrapping excepthook') + self.wrapped_excepthook += 1 + sys.excepthook = self.excepthook + + def unwrap(self, stdout: bool = False, stderr: bool = False) -> None: + if stdout: + self.unwrap_stdout() + + if stderr: + self.unwrap_stderr() + + def unwrap_stdout(self) -> None: + if self.wrapped_stdout > 1: + self.wrapped_stdout -= 1 + else: + # Also reset our own reference so needs_clear() and + # update_capturing() don't act on a stale wrapper + self.stdout = sys.stdout = self.original_stdout + self.wrapped_stdout = 0 + if not self.wrapped_stderr: + self.unwrap_excepthook() + + def unwrap_stderr(self) -> None: + if self.wrapped_stderr > 1: + self.wrapped_stderr -= 1 + else: + # Also reset our own reference so needs_clear() and + # update_capturing() don't act on a stale wrapper + self.stderr = sys.stderr = self.original_stderr + self.wrapped_stderr = 0 + if not self.wrapped_stdout: + self.unwrap_excepthook() + + def needs_clear(self) -> bool: # pragma: no cover + stdout_needs_clear = getattr(self.stdout, 'needs_clear', False) + stderr_needs_clear = getattr(self.stderr, 'needs_clear', False) + return stderr_needs_clear or stdout_needs_clear + + def flush(self) -> None: + if self.wrapped_stdout and isinstance(self.stdout, WrappingIO): + try: + self.stdout._flush() + except io.UnsupportedOperation: + self.wrapped_stdout = 0 + logger.warning( + 'Disabling stdout redirection, %r is not seekable', + sys.stdout, + ) + + if self.wrapped_stderr and isinstance(self.stderr, WrappingIO): + try: + self.stderr._flush() + except io.UnsupportedOperation: # pragma: no cover + self.wrapped_stderr = 0 + logger.warning( + 'Disabling stderr redirection, %r is not seekable', + sys.stderr, + ) + + def excepthook( + self, + exc_type: type[BaseException], + exc_value: BaseException, + exc_traceback: types.TracebackType | None, + ) -> None: + self.original_excepthook(exc_type, exc_value, exc_traceback) + self.flush() + + +class AttributeDict(dict): + """ + A dict that can be accessed with .attribute. + + >>> attrs = AttributeDict(spam=123) + + # Reading + + >>> attrs['spam'] + 123 + >>> attrs.spam + 123 + + # Read after update using attribute + + >>> attrs.spam = 456 + >>> attrs['spam'] + 456 + >>> attrs.spam + 456 + + # Read after update using dict access + + >>> attrs['spam'] = 123 + >>> attrs['spam'] + 123 + >>> attrs.spam + 123 + + # Read after update using dict access + + >>> del attrs.spam + >>> attrs['spam'] + Traceback (most recent call last): + ... + KeyError: 'spam' + >>> attrs.spam + Traceback (most recent call last): + ... + AttributeError: No such attribute: spam + >>> del attrs.spam + Traceback (most recent call last): + ... + AttributeError: No such attribute: spam + """ + + def __getattr__(self, name: str) -> int: + if name in self: + return self[name] + else: + raise AttributeError(f'No such attribute: {name}') + + def __setattr__(self, name: str, value: int) -> None: + self[name] = value + + def __delattr__(self, name: str) -> None: + if name in self: + del self[name] + else: + raise AttributeError(f'No such attribute: {name}') + + +logger: logging.Logger = logging.getLogger(__name__) +streams = StreamWrapper() +atexit.register(streams.flush) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index c60758ce..387b02eb 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1,38 +1,125 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# -# progressbar - Text progress bar library for Python. -# Copyright (c) 2005 Nilton Volpato -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - -'''Default ProgressBar widgets''' - -from __future__ import division, absolute_import, with_statement +from __future__ import annotations -import datetime -import math import abc -import sys -import pprint +import contextlib +import datetime +import functools +import logging +import typing + +# Ruff is being stupid and doesn't understand `ClassVar` if it comes from the +# `types` module +from typing import ClassVar + +from python_utils import containers, converters, types + +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_, str): + + def render_input(progress, data, width): + return input_ % data + + return render_input + else: + return input_ -from . import utils +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 + ): + # The fill length is based on the progress relative to + # min_value; the max() guards against a zero range and the + # min() keeps the marker within the allotted width when the + # value exceeds max_value (with max_error=False) + length = min( + width, + int( + (progress.value - progress.min_value) + / max(progress.max_value - progress.min_value, 1e-6) + * width, + ), + ) + return marker * length + else: + return marker -class FormatWidgetMixin(object): - '''Mixin to format widgets using a formatstring + if isinstance(marker, str): + marker = converters.to_unicode(marker) + # Ruff is silly at times... the format is not compatible with the check + marker_length_error = 'Markers are required to be 1 char' + assert utils.len_color(marker) == 1, marker_length_error + return wrapper(_marker, wrap) + else: + return wrapper(marker, wrap) + + +class FormatWidgetMixin(abc.ABC): + """Mixin to format widgets using a formatstring. Variables available: - max_value: The maximum value (can be None with iterators) @@ -45,376 +132,1524 @@ 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): + def __init__( + self, format: str, new_style: bool = False, **kwargs: typing.Any + ): + self.new_style = new_style self.format = format - super(FormatWidgetMixin, self).__init__() - def __call__(self, progress, data): - '''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 self.format % data + if self.new_style: + return format_.format(**data) + else: + return format_ % data except (TypeError, KeyError): - print >> sys.stderr, 'Error while formatting %r' % self.format - pprint.pprint(data, stream=sys.stderr) + logger.exception( + 'Error while formatting %r with data: %r', + format_, + data, + ) raise -class WidgetBase(object): - __metaclass__ = abc.ABCMeta - '''The base class for all widgets +class WidthWidgetMixin(abc.ABC): + """Mixing to make sure widgets are only visible if the screen is within a + specified size range so the progressbar fits on both large and small + screens. + + Variables available: + - min_width: Only display the widget if at least `min_width` is left + - max_width: Only display the widget if at most `max_width` is left + + >>> class Progress: + ... term_width = 0 + + >>> WidthWidgetMixin(5, 10).check_size(Progress) + False + >>> Progress.term_width = 5 + >>> WidthWidgetMixin(5, 10).check_size(Progress) + True + >>> Progress.term_width = 10 + >>> WidthWidgetMixin(5, 10).check_size(Progress) + True + >>> Progress.term_width = 11 + >>> WidthWidgetMixin(5, 10).check_size(Progress) + False + """ + + 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: ProgressBarMixinBase): + max_width = self.max_width + min_width = self.min_width + if min_width and min_width > progress.term_width: + return False + elif max_width and max_width < progress.term_width: # noqa: SIM103 + return False + else: + return True + + +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 TIME_SENSITIVE 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. - ''' - INTERVAL = None + + 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) -class AutoWidthWidgetBase(WidgetBase): - '''The base class for all variable width widgets. + 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, 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(seconds=1) + """ + + INTERVAL = datetime.timedelta(milliseconds=100) + + +class FormatLabel(FormatWidgetMixin, WidgetBase): + """Displays a formatted label. + + >>> label = FormatLabel('%(value)s', min_width=5, max_width=10) + >>> class Progress: + ... pass + >>> label = FormatLabel('{value} :: {value:^6}', new_style=True) + >>> str(label(Progress, dict(value='test'))) + 'test :: test ' + + """ + + mapping: ClassVar[types.Dict[str, types.Tuple[str, types.Any]]] = dict( + finished=('end_time', None), + last_update=('last_update_time', None), + max=('max_value', None), + seconds=('seconds_elapsed', None), + start=('start_time', None), + elapsed=('total_seconds_elapsed', utils.format_time), + value=('value', None), + ) + + def __init__(self, format: str, **kwargs: typing.Any): + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + for name, (key, transform) in self.mapping.items(): + with contextlib.suppress(KeyError, ValueError, IndexError): + if transform is None: + data[name] = data[key] + else: + data[name] = transform(data[key]) + return FormatWidgetMixin.__call__(self, progress, data, format) -class Timer(FormatWidgetMixin, TimeSensitiveWidgetBase): - '''WidgetBase which displays the elapsed seconds.''' - def __init__(self, format='Elapsed Time: %(time_elapsed)s'): - super(Timer, self).__init__(format=format) +class Timer(FormatLabel, TimeSensitiveWidgetBase): + """WidgetBase which displays the elapsed seconds.""" - @staticmethod - def format_time(seconds): - '''Formats time as the string "HH:MM:SS".''' - return str(datetime.timedelta(seconds=int(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') + FormatLabel.__init__(self, format=format, **kwargs) + TimeSensitiveWidgetBase.__init__(self, **kwargs) + + # This is exposed as a static method for backwards compatibility + format_time = staticmethod(utils.format_time) -class SamplesMixin(object): - def __init__(self, samples=10, key_prefix=None): - self.samples = samples - self.key_prefix = (self.__class__.__name__ or key_prefix) + '_' - def get_sample_times(self, progress, data): - return progress.extra.setdefault(self.key_prefix + 'sample_times', []) +class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta): + """ + Mixing for widgets that average multiple measurements. - def get_sample_values(self, progress, data): - return progress.extra.setdefault(self.key_prefix + 'sample_values', []) + Note that samples can be either an integer or a timedelta to indicate a + certain amount of time - def __call__(self, progress, data): + >>> class progress: + ... last_update_time = datetime.datetime.now() + ... value = 1 + ... extra = dict() + + >>> samples = SamplesMixin(samples=2) + >>> samples(progress, None, True) + (None, None) + >>> progress.last_update_time += datetime.timedelta(seconds=1) + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True + + >>> progress.last_update_time += datetime.timedelta(seconds=1) + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True + + >>> samples = SamplesMixin(samples=datetime.timedelta(seconds=1)) + >>> _, value = samples(progress, None) + >>> value + SliceableDeque([1, 1]) + + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True + """ + + def __init__( + self, + samples=datetime.timedelta(seconds=2), + key_prefix=None, + **kwargs, + ): + self.samples = samples + self.key_prefix = (key_prefix or self.__class__.__name__) + '_' + TimeSensitiveWidgetBase.__init__(self, **kwargs) + + def get_sample_times(self, progress: ProgressBarMixinBase, data: Data): + return progress.extra.setdefault( + f'{self.key_prefix}sample_times', + containers.SliceableDeque(), + ) + + def get_sample_values(self, progress: ProgressBarMixinBase, data: Data): + return progress.extra.setdefault( + f'{self.key_prefix}sample_values', + containers.SliceableDeque(), + ) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + delta: bool = False, + ): sample_times = self.get_sample_times(progress, data) sample_values = self.get_sample_values(progress, data) - if progress.value != progress.previous_value: + if sample_times: + sample_time = sample_times[-1] + else: + sample_time = datetime.datetime.min + + if progress.last_update_time - sample_time > self.INTERVAL: # Add a sample but limit the size to `num_samples` sample_times.append(progress.last_update_time) sample_values.append(progress.value) - if len(sample_times) > self.samples: + if isinstance(self.samples, datetime.timedelta): + minimum_time = progress.last_update_time - self.samples + while sample_times[2:] and minimum_time > sample_times[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 _eta(self, progress, data, value, elapsed): - if value == progress.min_value: - return 'ETA: --:--:--' + def __init__( + self, + format_not_started='ETA: --:--:--', + format_finished='Time: %(elapsed)8s', + format='ETA: %(eta)8s', + format_zero='ETA: 00:00:00', + format_na='ETA: N/A', + **kwargs, + ): + if '%s' in format and '%(eta)s' not in format: + format = format.replace('%s', '%(eta)s') + + Timer.__init__(self, **kwargs) + self.format_not_started = format_not_started + self.format_finished = format_finished + self.format = format + self.format_zero = format_zero + self.format_NA = format_na + + def _calculate_eta( + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, + ): + """Updates the widget to show the ETA or total time when finished.""" + if elapsed: + # The max() prevents zero division errors + per_item = elapsed.total_seconds() / max(value, 1e-6) + remaining = progress.max_value - data['value'] + return remaining * per_item + else: + return 0 + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + """Updates the widget to show the ETA or total time when finished.""" + if value is None: + # The per-item rate must be based on the progress relative to + # min_value, not the raw value + value = data['value'] - progress.min_value + + if elapsed is None: + elapsed = data['time_elapsed'] + + eta_na = False + try: + data['eta_seconds'] = self._calculate_eta( + progress, + data, + value=value, + elapsed=elapsed, + ) + except TypeError: + data['eta_seconds'] = None + eta_na = True + + data['eta'] = None + if data['eta_seconds']: + with contextlib.suppress(ValueError, OverflowError, OSError): + data['eta'] = utils.format_time(data['eta_seconds']) + + if data['value'] == progress.min_value: + fmt = self.format_not_started elif progress.end_time: - return 'Time: %s' % elapsed + fmt = self.format_finished + elif data['eta']: + fmt = self.format + elif eta_na: + fmt = self.format_NA else: - eta = elapsed * progress.max_value / value - elapsed - return 'ETA: %s' % self.format_time(eta) + fmt = self.format_zero + + return Timer.__call__(self, progress, data, format=fmt) + + +class AbsoluteETA(ETA): + """Widget which attempts to estimate the absolute time of arrival.""" + + def _calculate_eta( + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, + ): + eta_seconds = ETA._calculate_eta(self, progress, data, value, elapsed) + now = datetime.datetime.now() + try: + return now + datetime.timedelta(seconds=eta_seconds) + except OverflowError: # pragma: no cover + return datetime.datetime.max - def __call__(self, progress, data): - '''Updates the widget to show the ETA or total time when finished.''' - return self._eta(progress, data, data['value'], - data['total_seconds_elapsed']) + def __init__( + self, + format_not_started='Estimated finish time: ----/--/-- --:--:--', + format_finished='Finished at: %(elapsed)s', + format='Estimated finish time: %(eta)s', + **kwargs, + ): + ETA.__init__( + self, + format_not_started=format_not_started, + format_finished=format_finished, + format=format, + **kwargs, + ) class AdaptiveETA(ETA, SamplesMixin): - '''WidgetBase which attempts to estimate the time of arrival. + """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. - ''' + """ + + exponential_smoothing: bool + exponential_smoothing_factor: float + + def __init__( + self, + exponential_smoothing=True, + exponential_smoothing_factor=0.1, + **kwargs, + ): + self.exponential_smoothing = exponential_smoothing + self.exponential_smoothing_factor = exponential_smoothing_factor + ETA.__init__(self, **kwargs) + SamplesMixin.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + elapsed, value = SamplesMixin.__call__( + self, + progress, + data, + delta=True, + ) + if not elapsed: + value = None + elapsed = 0 + + return ETA.__call__(self, progress, data, value=value, elapsed=elapsed) - 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 - return ETA.__call__(self, progress, data) +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, + ) + ETA.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + if value is None: # pragma: no branch + # The per-item rate must be based on the progress relative to + # min_value, not the raw value + value = data['value'] - progress.min_value + + if elapsed is None: # pragma: no branch + elapsed = data['time_elapsed'] + + value = 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 = variable + self.unit = unit + self.prefixes = prefixes + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + value = data[self.variable] + if value is not None: + scaled, power = utils.scale_1024(value, len(self.prefixes)) else: - return self._eta(progress, data, values[-1] - values[0], - utils.timedelta_to_seconds(times[-1] - times[0])) + scaled = power = 0 + + data['scaled'] = scaled + data['prefix'] = self.prefixes[power] + data['unit'] = self.unit + + 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', unit='B', - prefixes=('', 'ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi')): + 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 - super(FileTransferSpeed, self).__init__(format=format) + self.inverse_format = inverse_format + FormatWidgetMixin.__init__(self, format=format, **kwargs) + TimeSensitiveWidgetBase.__init__(self, **kwargs) def _speed(self, value, elapsed): speed = float(value) / elapsed - power = min(int(math.log(speed, 2) / 10), len(self.prefixes) - 1) - scaled = speed / (2 ** (10 * power)) - return scaled, power - - 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 - - if elapsed > 2e-6 and value > 2e-6: # =~ 0 + return utils.scale_1024(speed, len(self.prefixes)) + + def __call__( + self, + progress: ProgressBarMixinBase, + data, + value=None, + total_seconds_elapsed=None, + ): + """Updates the widget with the current SI prefixed speed.""" + if value is None: + value = data['value'] + + elapsed = utils.deltas_to_seconds( + total_seconds_elapsed, + data['total_seconds_elapsed'], + ) + + if ( + value is not None + and elapsed is not None + and elapsed > 2e-6 + and value > 2e-6 + ): # =~ 0 scaled, power = self._speed(value, elapsed) else: scaled = power = 0 - data['scaled'] = scaled - data['prefix'] = self.prefixes[power] data['unit'] = self.unit - return FormatWidgetMixin.__call__(self, progress, data) + if power == 0 and 0 < scaled < 0.1: + # Slow transfers are shown as seconds per unit instead. Note + # that this is only done when there is actual data; before the + # first data arrives the regular format is used. + data['scaled'] = 1 / scaled + data['prefix'] = self.prefixes[0] + return FormatWidgetMixin.__call__( + self, + progress, + data, + self.inverse_format, + ) + else: + data['scaled'] = scaled + data['prefix'] = self.prefixes[power] + return FormatWidgetMixin.__call__(self, progress, data) class AdaptiveTransferSpeed(FileTransferSpeed, SamplesMixin): - '''WidgetBase for showing the transfer speed, based on the last X samples - ''' - - 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]) - + """Widget for showing the transfer speed based on the last X samples.""" + + def __init__(self, **kwargs: typing.Any): + FileTransferSpeed.__init__(self, **kwargs) + SamplesMixin.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data, + value=None, + total_seconds_elapsed=None, + ): + elapsed, value = SamplesMixin.__call__( + self, + progress, + data, + delta=True, + ) return FileTransferSpeed.__call__(self, progress, data, value, elapsed) -class AnimatedMarker(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): + 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] - - def __call__(self, progress, data, width=None): - '''Updates the widget to show the next marker or the first marker when - finished''' - + self.fill_wrap = create_wrapper(fill_wrap) + self.fill = create_marker(fill, self.fill_wrap) if fill else None + WidgetBase.__init__(self, **kwargs) + + def __call__(self, progress: ProgressBarMixinBase, data: Data, width=None): + """Updates the widget to show the next marker or the first marker when + finished. + """ if progress.end_time: + # When finished, keep a filling marker full instead of + # collapsing to a single character; a plain marker has no fill + # so it falls back to its default character. + if self.fill: + return self.fill(progress, data, width) 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 RotatingMarker = AnimatedMarker class Counter(FormatWidgetMixin, WidgetBase): + """Displays the current count.""" + + 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.""" - '''Displays the current count''' + 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 get_format( + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, + ): + # If percentage is not available, display N/A% + percentage = data.get('percentage', base.Undefined) + if not percentage and percentage != 0: + output = self.na + else: + output = FormatWidgetMixin.get_format(self, progress, data, format) + + return self._apply_colors(output, data) + + +class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase): + """Returns progress as a count of the total (e.g.: "5 of 47").""" + + max_width_cache: dict[ + str + | tuple[ + NumberT | types.Type[base.UnknownLength] | None, + NumberT | types.Type[base.UnknownLength] | None, + ], + types.Optional[int], + ] + + DEFAULT_FORMAT = '%(value_s)s of %(max_value_s)s' + + def __init__(self, format=DEFAULT_FORMAT, **kwargs: typing.Any): + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, format=format, **kwargs) + self.max_width_cache = dict() + # Pyright isn't happy when we set the key in the initialiser + self.max_width_cache['default'] = self.max_width or 0 + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, + ): + # If max_value is not available, display N/A + if data.get('max_value'): + data['max_value_s'] = data['max_value'] + else: + data['max_value_s'] = 'N/A' - def __init__(self, format='%(value)d'): - super(Counter, self).__init__(format=format) + # if value is not available it's the zeroth iteration + if data.get('value'): + data['value_s'] = data['value'] + else: + data['value_s'] = 0 + + formatted = FormatWidgetMixin.__call__( + self, + progress, + data, + format=format, + ) + + # Guess the maximum width from the min and max value + key = progress.min_value, progress.max_value + max_width: types.Optional[int] = self.max_width_cache.get( + key, + self.max_width, + ) + if not max_width: + temporary_data = data.copy() + for value in key: + if value is None: # pragma: no cover + continue + + temporary_data['value'] = value + if width := progress.custom_len( # pragma: no branch + FormatWidgetMixin.__call__( + self, + progress, + temporary_data, + format=format, + ), + ): + max_width = max(max_width or 0, width) + + self.max_width_cache[key] = max_width + + # Adjust the output to have a consistent size in all cases + if max_width: # pragma: no branch + formatted = formatted.rjust(max_width) + + return self._apply_colors(formatted, data) -class Percentage(FormatWidgetMixin, WidgetBase): - '''Displays the current percentage as a number with a percent sign.''' +class Bar(AutoWidthWidgetBase): + """A progress bar which stretches to fill the line.""" - def __init__(self, format='%(percentage)3d%%'): - super(Percentage, self).__init__(format=format) + fg: terminal.OptionalColor | None = colors.gradient + bg: terminal.OptionalColor | None = None + def __init__( + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=True, + marker_wrap=None, + **kwargs, + ): + """Creates a customizable progress bar. -class FormatLabel(Timer): + The callable takes the same parameters as the `__call__` method - '''Displays a formatted label''' + marker - string or callable object to use as a marker + left - string or callable object to use as a left border + right - string or callable object to use as a right border + fill - character to use for the empty part of the progress bar + fill_left - whether to fill from the left or the right + """ + self.marker = create_marker(marker, marker_wrap) + self.left = string_or_lambda(left) + self.right = string_or_lambda(right) + self.fill = string_or_lambda(fill) + self.fill_left = fill_left - mapping = { - 'elapsed': ('seconds_elapsed', Timer.format_time), - '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', Timer.format_time), - 'value': ('value', None), - } + AutoWidthWidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, + ): + """Updates the progress bar and its subcomponents.""" + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + marker = converters.to_unicode(self.marker(progress, data, width)) + fill = converters.to_unicode(self.fill(progress, data, width)) + + # Make sure we ignore invisible characters when filling + width += len(marker) - progress.custom_len(marker) - def __init__(self, format): - self.format = format + if self.fill_left: + marker = marker.ljust(width, fill) + else: + marker = marker.rjust(width, fill) - def __call__(self, progress, data): - for name, (key, transform) in self.mapping.items(): - try: - if transform is None: - data[name] = data[key] - else: - data[name] = transform(data[key]) - except: # pragma: no cover - pass + if color: + marker = self._apply_colors(marker, data) - return FormatWidgetMixin.__call__(self, progress, data) + return left + marker + right -class SimpleProgress(FormatWidgetMixin, WidgetBase): +class ReverseBar(Bar): + """A bar which has a marker that goes from right to left.""" - '''Returns progress as a count of the total (e.g.: "5 of 47")''' + def __init__( + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=False, + **kwargs, + ): + """Creates a customizable progress bar. - def __init__(self, format='%(value)d of %(max_value)d'): - super(SimpleProgress, self).__init__(format=format) + 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, + ) + + +class BouncingBar(Bar, TimeSensitiveWidgetBase): + """A bar which has a marker which bounces from side to side.""" + + INTERVAL = datetime.timedelta(milliseconds=100) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, + ): + """Updates the progress bar and its subcomponents.""" + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + marker = converters.to_unicode(self.marker(progress, data, width)) + + fill = converters.to_unicode(self.fill(progress, data, width)) + + if width: # pragma: no branch + value = int( + data['total_seconds_elapsed'] / self.INTERVAL.total_seconds(), + ) + + a = value % width + b = width - a - 1 + if value % (width * 2) >= width: + a, b = b, a + + if self.fill_left: + marker = a * fill + marker + b * fill + else: + marker = b * fill + marker + a * fill + return left + marker + right -class Bar(AutoWidthWidgetBase): - '''A progress bar which stretches to fill the line.''' - def __init__(self, marker='#', left='|', right='|', fill=' ', - fill_left=True): - '''Creates a customizable progress bar. +class FormatCustomText(FormatWidgetMixin, WidgetBase): + mapping: types.Dict[str, types.Any] = dict() # noqa: RUF012 + copy = False - The callable takes the same parameters as the `__call__` method + def __init__( + self, + format: str, + mapping: types.Optional[types.Dict[str, types.Any]] = None, + **kwargs, + ): + self.format = format + self.mapping = mapping or self.mapping + FormatWidgetMixin.__init__(self, format=format, **kwargs) + WidgetBase.__init__(self, **kwargs) + + def update_mapping(self, **mapping: types.Dict[str, types.Any]): + self.mapping.update(mapping) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + return FormatWidgetMixin.__call__( + self, + progress, + self.mapping, + format or self.format, + ) + + +class VariableMixin: + """Mixin to display a custom user variable.""" + + def __init__(self, name, **kwargs: typing.Any): + if not isinstance(name, str): + raise TypeError('Variable(): argument must be a string') + if len(name.split()) > 1: + raise ValueError('Variable(): argument must be single word') + self.name = name + + +class MultiRangeBar(Bar, VariableMixin): + """ + A bar with multiple sub-ranges, each represented by a different symbol. + + 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 - marker - string or callable object to use as a marker - left - string or callable object to use as a left border - right - string or callable object to use as a right border - fill - character to use for the empty part of the progress bar - fill_left - whether to fill from the left or the right - ''' - def string_or_lambda(input_): - if isinstance(input_, basestring): - return lambda progress, data, width: input_ % data - else: - return input_ + return left + middle + right - def _marker(marker): - def __marker(progress, data, width): - if progress.max_value > 0: - length = int(progress.value / progress.max_value * width) - return (marker * length) + +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). A zero maximum means the total + # is not known (yet), so no progress can be shown. + progress_value, progress_max = value + if progress_max: + value = float(progress_value) / float(progress_max) else: - return '' + value = 0.0 - if isinstance(marker, basestring): - assert len(marker) == 1, 'Markers are required to be 1 char' - return __marker - else: - return marker + 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 - self.marker = _marker(marker) + +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) - self.fill = string_or_lambda(fill) - self.fill_left = fill_left - super(Bar, self).__init__() + AutoWidthWidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + ): + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + + max_value = progress.max_value + if ( + max_value is not base.UnknownLength + and typing.cast(float, max_value) > 0 + ): + percent = progress.value / max_value # type: ignore + else: + percent = 0 + + num_chars = percent * width - def __call__(self, progress, data, width): - '''Updates the progress bar and its subcomponents''' + marker = self.markers[-1] * int(num_chars) - left = self.left(progress, data, width) - right = self.right(progress, data, width) - width -= len(left) + len(right) - marker = self.marker(progress, data, width) - fill = self.fill(progress, data, width) + if marker_idx := int((num_chars % 1) * (len(self.markers) - 1)): + marker += self.markers[marker_idx] - if self.fill_left: - marker = marker.ljust(width, fill) - else: - marker = marker.rjust(width, fill) + 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 ReverseBar(Bar): +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) - '''A bar which has a marker which bounces from side to side.''' + def __call__( # type: ignore + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + format: FormatString = None, + ): + return super().__call__(progress, data, width, format=format) - def __init__(self, marker='#', left='|', right='|', fill=' ', - fill_left=False): - '''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) +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 -class BouncingBar(Bar): + 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 - def update(self, progress, width): - '''Updates the progress bar and its subcomponents''' + return self.format.format(**context) - left, marker, right = (i for i in (self.left, self.marker, self.right)) - width -= len(left) + len(right) +class DynamicMessage(Variable): + """Kept for backwards compatibility, please use `Variable` instead.""" - if progress.finished: - return '%s%s%s' % (left, width * marker, right) - position = int(progress.value % (width * 2 - 1)) - if position > width: - position = width * 2 - position - lpad = self.fill * (position - 1) - rpad = self.fill * (width - len(marker) - len(lpad)) +class CurrentTime(FormatWidgetMixin, TimeSensitiveWidgetBase): + """Widget which displays the current (date)time with seconds resolution.""" - # Swap if we want to bounce the other way - if not self.fill_left: - rpad, lpad = lpad, rpad + INTERVAL = datetime.timedelta(seconds=1) - return '%s%s%s%s%s' % (left, lpad, marker, rpad, right) + def __init__( + self, + format='Current Time: %(current_time)s', + microseconds=False, + **kwargs, + ): + self.microseconds = microseconds + FormatWidgetMixin.__init__(self, format=format, **kwargs) + TimeSensitiveWidgetBase.__init__(self, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: types.Optional[str] = None, + ): + data['current_time'] = self.current_time() + data['current_datetime'] = self.current_datetime() + + return FormatWidgetMixin.__call__(self, progress, data, format=format) + + def current_datetime(self): + now = datetime.datetime.now() + if not self.microseconds: + now = now.replace(microsecond=0) + + return now + + def current_time(self): + return self.current_datetime().time() + + +class JobStatusBar(Bar, VariableMixin): + """ + Widget which displays the job status as markers on the bar. + + The status updates can be given either as a boolean or as a string. If it's + a string, it will be displayed as-is. If it's a boolean, it will be + displayed as a marker (default: '█' for success, 'X' for failure) + configurable through the `success_marker` and `failure_marker` parameters. + + Args: + name: The name of the variable to use for the status updates. + left: The left border of the bar. + right: The right border of the bar. + fill: The fill character of the bar. + fill_left: Whether to fill the bar from the left or the right. + success_fg_color: The foreground color to use for successful jobs. + success_bg_color: The background color to use for successful jobs. + success_marker: The marker to use for successful jobs. + failure_fg_color: The foreground color to use for failed jobs. + failure_bg_color: The background color to use for failed jobs. + failure_marker: The marker to use for failed jobs. + """ + + 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) + # Drop the oldest markers when they no longer fit the + # available width + while ( + len(self.job_markers) > 1 + and progress.custom_len(''.join(self.job_markers)) > width + ): + self.job_markers.pop(0) + + marker = ''.join(self.job_markers) + width -= progress.custom_len(marker) + + fill = converters.to_unicode(self.fill(progress, data, width)) + fill = self._apply_colors(fill * max(width, 0), 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..17531867 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,221 @@ +[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.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.15', + '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.10' +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 +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 e852908a..08a11301 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,19 +5,24 @@ python_files = addopts = --cov progressbar - --cov-report term-missing - --cov-report html - --clearcache + --cov-report=html + --cov-report=term-missing + --cov-report=xml + --cov-config=./pyproject.toml + --no-cov-on-fail --doctest-modules - --pep8 - --flakes - progressbar - tests -pep8ignore = - *.py W391 - docs/*.py ALL +norecursedirs = + .* + _* + build + dist + docs + progressbar/terminal/os_specific + tmp* -flakes-ignore = - docs/*.py ALL +filterwarnings = + ignore::DeprecationWarning +markers = + no_freezegun: Disable automatic freezegun wrapping diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index b786a0fb..00000000 --- a/requirements_test.txt +++ /dev/null @@ -1,7 +0,0 @@ -flake8 -pytest -pytest-cov -pytest-cache -pytest-pep8 -pytest-flakes -sphinx 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 8c03d3ee..00000000 --- a/setup.cfg +++ /dev/null @@ -1,30 +0,0 @@ -[metadata] -description-file = README.rst - -[nosetests] -verbosity=3 -detailed-errors=1 -debug=nose.loader - -with-doctest=1 - -with-coverage=1 -cover-package=progressbar -cover-branches=1 -cover-min-percentage=100 - -#pdb=1 -pdb-failures=1 - -with-tissue=1 -tissue-ignore=W391 -tissue-package=progressbar - -[build_sphinx] -source-dir = docs/ -build-dir = docs/_build -all_files = 1 - -[upload_sphinx] -upload-dir = docs/_build/html - diff --git a/setup.py b/setup.py deleted file mode 100644 index 52b1cc4d..00000000 --- a/setup.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/python - -import sys -from setuptools import setup, find_packages -from setuptools.command.test import test as TestCommand -from progressbar import metadata - - -class PyTest(TestCommand): - def finalize_options(self): - TestCommand.finalize_options(self) - self.test_args = [] - self.test_suite = True - - def run_tests(self): - # import here, cause outside the eggs aren't loaded - import pytest - errno = pytest.main(self.test_args) - sys.exit(errno) - -if __name__ == '__main__': - setup( - name=metadata.__package_name__, - version=metadata.__version__, - packages=find_packages(), - description=metadata.__doc__.split('\n')[0], - long_description=metadata.__doc__, - author=metadata.__author__, - maintainer=metadata.__author__, - author_email=metadata.__author_email__, - maintainer_email=metadata.__author_email__, - url=metadata.__url__, - license='LICENSE.txt', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: ' - 'GNU Library or Lesser General Public License (LGPL)', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.4', - 'Programming Language :: Python :: 2.5', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: Pypy', - 'Topic :: Software Development :: Libraries', - 'Topic :: Software Development :: User Interfaces', - 'Topic :: Terminals' - ], - ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..59cbe7d1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging +import time +import timeit +from datetime import datetime, timezone + +import freezegun +import pytest + +import progressbar + +LOG_LEVELS: dict[str, int] = { + '0': logging.ERROR, + '1': logging.WARNING, + '2': logging.INFO, + '3': logging.DEBUG, +} + + +def pytest_configure(config) -> None: + logging.basicConfig( + level=LOG_LEVELS.get(config.option.verbose, logging.DEBUG), + ) + + +@pytest.fixture(autouse=True) +def small_interval(monkeypatch) -> None: + # Remove the update limit for tests by default + monkeypatch.setattr( + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + 1e-6, + ) + monkeypatch.setattr(timeit, 'default_timer', time.time) + + +@pytest.fixture(autouse=True) +def sleep_faster(monkeypatch): + # Compute the local UTC offset so freezegun uses the same timezone as + # the local system. Using datetime.now(timezone.utc).astimezone() avoids + # the deprecated datetime.utcnow() (deprecated since Python 3.12). + local_offset = datetime.now(timezone.utc).astimezone().utcoffset() + offset_hours = local_offset.total_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/custom_widgets.py b/tests/custom_widgets.py deleted file mode 100644 index 4fb02958..00000000 --- a/tests/custom_widgets.py +++ /dev/null @@ -1,32 +0,0 @@ -import progressbar - - -class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): - "It's bigger between 45 and 80 percent" - - def update(self, pbar): - if 45 < pbar.percentage() < 80: - return 'Bigger Now ' + progressbar.FileTransferSpeed.update(self, - pbar) - else: - return progressbar.FileTransferSpeed.update(self, pbar) - - -def test_crazy_file_transfer_speed_widget(): - widgets = [ - # CrazyFileTransferSpeed(), - ' <<<', - progressbar.Bar(), - '>>> ', - progressbar.Percentage(), - ' ', - progressbar.ETA(), - ] - - p = progressbar.ProgressBar(widgets=widgets, max_value=1000) - # maybe do something - p.start() - for i in range(0, 200, 5): - # do something - p.update(i + 1) - p.finish() diff --git a/tests/failure.py b/tests/failure.py deleted file mode 100644 index 18e0f4d1..00000000 --- a/tests/failure.py +++ /dev/null @@ -1,86 +0,0 @@ -import pytest -import progressbar - - -def test_missing_format_values(): - with pytest.raises(KeyError): - p = progressbar.ProgressBar( - widgets=[progressbar.widgets.FormatLabel('%(x)s')], - ) - p.update(5) - - -def test_max_smaller_than_min(): - 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''' - p = progressbar.ProgressBar() - p.start() - for i in range(5): - p.update(i) - - -def test_correct_max_value(): - '''Looping up to 5 when max_value is 10? No problem''' - p = progressbar.ProgressBar(max_value=10) - for i in range(5): - p.update(i) - - -def test_minus_max_value(): - '''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''' - p = progressbar.ProgressBar(max_value=0) - - p.update(0) - with pytest.raises(ValueError): - p.update(1) - - -def test_one_max_value(): - '''max_value of one, another corner case''' - p = progressbar.ProgressBar(max_value=1) - - p.update(0) - p.update(0) - p.update(1) - with pytest.raises(ValueError): - p.update(2) - - -def test_changing_max_value(): - '''Changing max_value? No problem''' - p = progressbar.ProgressBar(max_value=10)(range(20), max_value=20) - for i in p: - pass - - -def test_backwards(): - '''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!''' - p = progressbar.ProgressBar(max_value=5) - for i in range(5): - p.update(i) - - with pytest.raises(ValueError): - for i in range(5, 10): - p.update(i) - - diff --git a/tests/iterators.py b/tests/iterators.py deleted file mode 100644 index d8c6c01c..00000000 --- a/tests/iterators.py +++ /dev/null @@ -1,55 +0,0 @@ -import time -import pytest -import progressbar - - -def test_list(): - '''Progressbar can guess max_value automatically.''' - p = progressbar.ProgressBar() - for i in p(range(10)): - time.sleep(0.001) - - -def test_iterator_with_max_value(): - '''Progressbar can't guess max_value.''' - p = progressbar.ProgressBar(max_value=10) - for i in p((i for i in range(10))): - time.sleep(0.001) - - -def test_iterator_without_max_value_error(): - '''Progressbar can't guess max_value.''' - p = progressbar.ProgressBar() - with pytest.raises(TypeError): - for i in p((i for i in range(10))): - time.sleep(0.001) - - -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))): - time.sleep(0.001) - - -def test_iterator_with_incorrect_max_value(): - '''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))): - time.sleep(0.001) - - -def test_adding_value(): - p = progressbar.ProgressBar(max_value=10) - p.start() - p.update(5) - p += 5 - with pytest.raises(ValueError): - p += 5 - diff --git a/tests/misc.py b/tests/misc.py deleted file mode 100644 index 57087fd3..00000000 --- a/tests/misc.py +++ /dev/null @@ -1,10 +0,0 @@ -from progressbar import metadata - - -def test_metadata(): - assert metadata.__package_name__ - assert metadata.__author__ - assert metadata.__author_email__ - assert metadata.__url__ - assert metadata.__date__ - assert metadata.__version__ 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/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..9628e3fa --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +-e.[tests] diff --git a/tests/speed.py b/tests/speed.py deleted file mode 100644 index 9d0aa290..00000000 --- a/tests/speed.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest -import progressbar - - -@pytest.mark.parametrize('total_seconds_elapsed,value,expected', [ - (1, 0, ' 0.0 B'), - (1, 1, ' 1.0 B'), - (1, 2 ** 10 - 1, '1023.0 B'), - (1, 2 ** 10 + 0, ' 1.0 kiB'), - (1, 2 ** 20, ' 1.0 MiB'), - (1, 2 ** 30, ' 1.0 GiB'), - (1, 2 ** 40, ' 1.0 TiB'), - (1, 2 ** 50, ' 1.0 PiB'), - (1, 2 ** 60, ' 1.0 EiB'), - (1, 2 ** 70, ' 1.0 ZiB'), - (1, 2 ** 80, ' 1.0 YiB'), - (1, 2 ** 90, '1024.0 YiB'), -]) -def test_file_transfer_speed(total_seconds_elapsed, value, expected): - widget = progressbar.FileTransferSpeed() - assert widget(None, dict( - total_seconds_elapsed=total_seconds_elapsed, - value=value, - )) == expected - diff --git a/tests/terminal.py b/tests/terminal.py deleted file mode 100644 index bd3e49ed..00000000 --- a/tests/terminal.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import print_function - -import sys -import fcntl -import signal -import progressbar - - -def test_left_justify(): - '''Left justify using the terminal width''' - p = progressbar.ProgressBar( - widgets=[progressbar.BouncingBar(marker=progressbar.RotatingMarker())], - max_value=100, term_width=20, left_justify=True) - - assert p._env_size() is not None - for i in range(100): - p.update(i) - - -def test_right_justify(): - '''Right justify using the terminal width''' - p = progressbar.ProgressBar( - widgets=[progressbar.BouncingBar(marker=progressbar.RotatingMarker())], - max_value=100, term_width=20, left_justify=False) - - assert p._env_size() is not None - for i in range(100): - p.update(i) - - -def test_auto_width(monkeypatch): - '''Right justify using the terminal width''' - - def ioctl(*args): - return '\xbf\x00\xeb\x00\x00\x00\x00\x00' - - def fake_signal(signal, func): - pass - - monkeypatch.setattr(fcntl, 'ioctl', ioctl) - monkeypatch.setattr(signal, 'signal', fake_signal) - p = progressbar.ProgressBar( - widgets=[progressbar.BouncingBar(marker=progressbar.RotatingMarker())], - max_value=100, left_justify=True, term_width=None) - - assert p._env_size() is not None - for i in range(100): - p.update(i) - - -def test_fill_right(): - '''Right justify using the terminal width''' - p = progressbar.ProgressBar( - widgets=[progressbar.BouncingBar(fill_left=False)], - max_value=100, term_width=20) - - assert p._env_size() is not None - for i in range(100): - p.update(i) - - -def test_fill_left(): - '''Right justify using the terminal width''' - p = progressbar.ProgressBar( - widgets=[progressbar.BouncingBar(fill_left=True)], - max_value=100, term_width=20) - - assert p._env_size() is not None - for i in range(100): - p.update(i) - - -def test_stdout_redirection(): - p = progressbar.ProgressBar(max_value=10, redirect_stdout=True) - - for i in range(10): - print('', file=sys.stdout) - p.update(i) - - -def test_stderr_redirection(): - p = progressbar.ProgressBar(max_value=10, redirect_stderr=True) - - for i in range(10): - print('', file=sys.stderr) - p.update(i) - - -def test_stdout_stderr_redirection(): - p = progressbar.ProgressBar(max_value=10, redirect_stdout=True, - redirect_stderr=True) - p.start() - - for i in range(10): - print('', file=sys.stdout) - print('', file=sys.stderr) - p.update(i) - - p.finish() - - -def test_resize(monkeypatch): - def ioctl(*args): - return '\xbf\x00\xeb\x00\x00\x00\x00\x00' - - def fake_signal(signal, func): - pass - - monkeypatch.setattr(fcntl, 'ioctl', ioctl) - monkeypatch.setattr(signal, 'signal', fake_signal) - - p = progressbar.ProgressBar(max_value=10) - p.start() - - for i in range(10): - p.update(i) - p._handle_resize() - - p.finish() - diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py new file mode 100644 index 00000000..4ce1364b --- /dev/null +++ b/tests/test_algorithms.py @@ -0,0 +1,80 @@ +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 is None + + +@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: + # The first update seeds the average, so blending starts from an + # explicit zero observation: alpha * new_value + (1 - alpha) * 0 + ema = algorithms.ExponentialMovingAverage(alpha) + ema.update(0, timedelta(seconds=1)) + 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 is None + assert dema.ema2 is None + + +@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: + # Seeded with an explicit zero observation, a single update yields + # ema1 = alpha * v, ema2 = alpha^2 * v, so the result is + # alpha * v * (2 - alpha) which matches the historical values + dema = algorithms.DoubleExponentialMovingAverage(alpha) + dema.update(0, timedelta(seconds=1)) + result = dema.update(new_value, timedelta(seconds=1)) + assert result == pytest.approx(expected) + + +# Additional test functions can be added here as needed. + + +def test_ema_seeds_from_first_value() -> None: + # Regression: B8 - the average started at 0, biasing early values + # toward zero instead of the first observation. + ema = algorithms.ExponentialMovingAverage(0.5) + assert ema.update(100, timedelta(seconds=1)) == 100 + assert ema.update(50, timedelta(seconds=1)) == 75 + + +def test_dema_seeds_from_first_value() -> None: + # Regression: B8 - same zero bias for the double EMA. + dema = algorithms.DoubleExponentialMovingAverage(0.5) + assert dema.update(100, timedelta(seconds=1)) == 100 + assert dema.update(50, timedelta(seconds=1)) == 62.5 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..3c0f5fb4 --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,432 @@ +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' + + +def test_hsl_interpolate_preserves_components() -> None: + # Regression: C1 - interpolate() swapped the saturation and lightness + # arguments, corrupting every HSL gradient blend. + start_color = terminal.HSL(0, 100, 25) + end_color = terminal.HSL(0, 100, 75) + + assert start_color.interpolate(end_color, 0.5) == terminal.HSL(0, 100, 50) + + +@pytest.mark.parametrize('value', ['1', 'true', 'on']) +def test_color_support_force_color_flag(monkeypatch, value) -> None: + # Regression: C8 - the conventional FORCE_COLOR=1 left color support + # at NONE because only depth-style values were recognised. + if os.name == 'nt': + monkeypatch.setattr(os, 'name', 'posix') + + monkeypatch.setenv('FORCE_COLOR', value) + assert env.ColorSupport.from_env() == env.ColorSupport.XTERM_TRUECOLOR diff --git a/tests/test_custom_widgets.py b/tests/test_custom_widgets.py new file mode 100644 index 00000000..b0a272e4 --- /dev/null +++ b/tests/test_custom_widgets.py @@ -0,0 +1,95 @@ +import time + +import pytest + +import progressbar + + +class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): + "It's bigger between 45 and 80 percent" + + def update(self, pbar): + if 45 < pbar.percentage() < 80: + value = progressbar.FileTransferSpeed.update(self, pbar) + return f'Bigger Now {value}' + else: + return progressbar.FileTransferSpeed.update(self, pbar) + + +def test_crazy_file_transfer_speed_widget() -> None: + widgets = [ + # CrazyFileTransferSpeed(), + ' <<<', + progressbar.Bar(), + '>>> ', + progressbar.Percentage(), + ' ', + progressbar.ETA(), + ] + + p = progressbar.ProgressBar(widgets=widgets, max_value=1000) + # maybe do something + p.start() + for i in range(0, 200, 5): + # do something + time.sleep(0.1) + p.update(i + 1) + p.finish() + + +def test_variable_widget_widget() -> None: + widgets = [ + ' [', + progressbar.Timer(), + '] ', + progressbar.Bar(), + ' (', + progressbar.ETA(), + ') ', + progressbar.Variable('loss'), + progressbar.Variable('text'), + progressbar.Variable('error', precision=None), + progressbar.Variable('missing'), + progressbar.Variable('predefined'), + ] + + p = progressbar.ProgressBar( + widgets=widgets, + max_value=1000, + variables=dict(predefined='predefined'), + ) + p.start() + print('time', time, time.sleep) + for i in range(0, 200, 5): + time.sleep(0.1) + p.update(i + 1, loss=0.5, text='spam', error=1) + + i += 1 + p.update(i, text=None) + i += 1 + p.update(i, text=False) + i += 1 + p.update(i, text=True, error='a') + with pytest.raises(TypeError): + p.update(i, non_existing_variable='error!') + p.finish() + + +def test_format_custom_text_widget() -> None: + widget = progressbar.FormatCustomText( + 'Spam: %(spam).1f kg, eggs: %(eggs)d', + dict( + spam=0.25, + eggs=3, + ), + ) + + bar = progressbar.ProgressBar( + widgets=[ + widget, + ], + ) + + for i in bar(range(5)): + widget.update_mapping(eggs=i * 2) + assert widget.mapping['eggs'] == bar.widgets[0].mapping['eggs'] diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 00000000..43071632 --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,25 @@ +import pytest + +import progressbar + + +@pytest.mark.parametrize( + 'value,expected', + [ + (None, ' 0.0 B'), + (1, ' 1.0 B'), + (2**10 - 1, '1023.0 B'), + (2**10 + 0, ' 1.0 KiB'), + (2**20, ' 1.0 MiB'), + (2**30, ' 1.0 GiB'), + (2**40, ' 1.0 TiB'), + (2**50, ' 1.0 PiB'), + (2**60, ' 1.0 EiB'), + (2**70, ' 1.0 ZiB'), + (2**80, ' 1.0 YiB'), + (2**90, '1024.0 YiB'), + ], +) +def test_data_size(value, expected) -> None: + widget = progressbar.DataSize() + assert widget(None, dict(value=value)) == expected diff --git a/tests/test_data_transfer_bar.py b/tests/test_data_transfer_bar.py new file mode 100644 index 00000000..e8f5c577 --- /dev/null +++ b/tests/test_data_transfer_bar.py @@ -0,0 +1,31 @@ +import io + +import progressbar +from progressbar import DataTransferBar + + +def test_known_length() -> None: + dtb = DataTransferBar().start(max_value=50) + for i in range(50): + dtb.update(i) + dtb.finish() + + +def test_unknown_length() -> None: + dtb = DataTransferBar().start(max_value=progressbar.UnknownLength) + for i in range(50): + dtb.update(i) + dtb.finish() + + +def test_file_transfer_speed_before_any_data() -> None: + # Regression: B6 - before any data was transferred the widget + # rendered '0.0 s/B' using the inverse format. + widget = progressbar.FileTransferSpeed() + bar = progressbar.ProgressBar( + max_value=10, widgets=[widget], fd=io.StringIO(), term_width=60 + ) + bar.start() + output = widget(bar, bar.data()) + assert 's/' not in output + bar.finish(dirty=True) 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/empty.py b/tests/test_empty.py similarity index 71% rename from tests/empty.py rename to tests/test_empty.py index de6bf09a..f326e384 100644 --- a/tests/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 new file mode 100644 index 00000000..e7c69e3e --- /dev/null +++ b/tests/test_end.py @@ -0,0 +1,56 @@ +import pytest + +import progressbar + + +@pytest.fixture(autouse=True) +def large_interval(monkeypatch) -> None: + # Remove the update limit for tests by default + monkeypatch.setattr( + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + 0.1, + ) + + +def test_end() -> None: + m = 24514315 + p = progressbar.ProgressBar( + widgets=[progressbar.Percentage(), progressbar.Bar()], + max_value=m, + ) + + for x in range(0, m, 8192): + p.update(x) + + data = p.data() + assert data['percentage'] < 100.0 + + p.finish() + + data = p.data() + assert data['percentage'] >= 100.0 + + assert p.value == m + + +def test_end_100(monkeypatch) -> None: + assert progressbar.ProgressBar._MINIMUM_UPDATE_INTERVAL == 0.1 + p = progressbar.ProgressBar( + widgets=[progressbar.Percentage(), progressbar.Bar()], + max_value=103, + ) + + for x in range(102): + p.update(x) + + data = p.data() + import pprint + + pprint.pprint(data) + assert data['percentage'] < 100.0 + + p.finish() + + data = p.data() + assert data['percentage'] >= 100.0 diff --git a/tests/test_failure.py b/tests/test_failure.py new file mode 100644 index 00000000..f2c36b04 --- /dev/null +++ b/tests/test_failure.py @@ -0,0 +1,190 @@ +import gc +import io +import logging +import sys +import time + +import pytest + +import progressbar +from progressbar import utils + + +def test_missing_format_values(caplog) -> None: + caplog.set_level(logging.CRITICAL, logger='progressbar.widgets') + with pytest.raises(KeyError): + p = progressbar.ProgressBar( + widgets=[progressbar.widgets.FormatLabel('%(x)s')], + ) + p.update(5) + + +def test_max_smaller_than_min() -> None: + with pytest.raises(ValueError): + progressbar.ProgressBar(min_value=10, max_value=5) + + +def test_no_max_value() -> None: + """Looping up to 5 without max_value? No problem""" + p = progressbar.ProgressBar() + p.start() + for i in range(5): + time.sleep(1) + p.update(i) + + +def test_correct_max_value() -> None: + """Looping up to 5 when max_value is 10? No problem""" + p = progressbar.ProgressBar(max_value=10) + for i in range(5): + time.sleep(1) + p.update(i) + + +def test_minus_max_value() -> None: + """negative max_value, shouldn't work""" + p = progressbar.ProgressBar(min_value=-2, max_value=-1) + + with pytest.raises(ValueError): + p.update(-1) + + +def test_zero_max_value() -> None: + """max_value of zero, it could happen""" + p = progressbar.ProgressBar(max_value=0) + + p.update(0) + with pytest.raises(ValueError): + p.update(1) + + +def test_one_max_value() -> None: + """max_value of one, another corner case""" + p = progressbar.ProgressBar(max_value=1) + + p.update(0) + p.update(0) + p.update(1) + with pytest.raises(ValueError): + p.update(2) + + +def test_changing_max_value() -> None: + """Changing max_value? No problem""" + p = progressbar.ProgressBar(max_value=10)(range(20), max_value=20) + for _i in p: + time.sleep(1) + + +def test_backwards() -> None: + """progressbar going backwards""" + p = progressbar.ProgressBar(max_value=1) + + p.update(1) + p.update(0) + + +def test_incorrect_max_value() -> None: + """Looping up to 10 when max_value is 5? This is madness!""" + p = progressbar.ProgressBar(max_value=5) + for i in range(5): + time.sleep(1) + p.update(i) + + with pytest.raises(ValueError): + for i in range(5, 10): + time.sleep(1) + p.update(i) + + +def test_deprecated_maxval() -> None: + with pytest.warns(DeprecationWarning): + progressbar.ProgressBar(maxval=5) + + +def test_deprecated_poll() -> None: + with pytest.warns(DeprecationWarning): + progressbar.ProgressBar(poll=5) + + +def test_deprecated_currval() -> None: + with pytest.warns(DeprecationWarning): + bar = progressbar.ProgressBar(max_value=5) + bar.update(2) + assert bar.currval == 2 + + +def test_unexpected_update_keyword_arg() -> None: + p = progressbar.ProgressBar(max_value=10) + with pytest.raises(TypeError): + for i in range(10): + time.sleep(1) + p.update(i, foo=10) + + +def test_variable_not_str() -> None: + with pytest.raises(TypeError): + progressbar.Variable(1) + + +def test_variable_too_many_strs() -> None: + with pytest.raises(ValueError): + progressbar.Variable('too long') + + +def test_negative_value() -> None: + bar = progressbar.ProgressBar(max_value=10) + with pytest.raises(ValueError): + bar.update(value=-1) + + +def test_increment() -> None: + bar = progressbar.ProgressBar(max_value=10) + bar.increment() + del bar + + +def test_unexpected_update_keyword_arg_message() -> None: + # Regression: A3 - the error message contained the literal text + # '{key!r}' because the string was not an f-string. + bar = progressbar.ProgressBar(max_value=10) + with pytest.raises(TypeError, match='foo'): + bar.update(1, foo=10) + + +def test_iterable_interrupt_unwraps_stdout() -> None: + # Regression #212: when an iterable-wrapped bar (no context manager) is + # interrupted by an exception in the loop body, the bar must still be + # finished and sys.stdout must be unwrapped. + original = sys.stdout + bar = progressbar.ProgressBar(redirect_stdout=True, fd=io.StringIO()) + with pytest.raises(ValueError): + for i in bar(range(100)): + if i == 3: + raise ValueError('boom') + gc.collect() + assert bar._finished + assert sys.stdout is original + assert not isinstance(sys.stdout, utils.WrappingIO) + + +def test_iterable_break_unwraps_stdout() -> None: + # Regression #212: breaking out of an iterable-wrapped bar must also + # finish the bar and unwrap sys.stdout. + original = sys.stdout + bar = progressbar.ProgressBar(redirect_stdout=True, fd=io.StringIO()) + for i in bar(range(100)): + if i == 3: + break + gc.collect() + assert bar._finished + assert sys.stdout is original + assert not isinstance(sys.stdout, utils.WrappingIO) + + +def test_iterable_direct_next_still_works() -> None: + # The generator-based __iter__ must not break direct iterator usage. + bar = progressbar.ProgressBar(max_value=10, fd=io.StringIO()) + it = bar(range(3)) + assert next(it) == 0 + assert next(it) == 1 diff --git a/tests/test_flush.py b/tests/test_flush.py new file mode 100644 index 00000000..edade1c3 --- /dev/null +++ b/tests/test_flush.py @@ -0,0 +1,17 @@ +import time + +import progressbar + + +def test_flush() -> None: + """Left justify using the terminal width""" + p = progressbar.ProgressBar(poll_interval=0.001) + p.print('hello') + + for i in range(10): + print('pre-updates', p.updates) + p.update(i) + print('need update?', p._needs_update()) + if i > 5: + time.sleep(0.1) + print('post-updates', p.updates) diff --git a/tests/test_iterators.py b/tests/test_iterators.py new file mode 100644 index 00000000..d4474e7e --- /dev/null +++ b/tests/test_iterators.py @@ -0,0 +1,61 @@ +import time + +import pytest + +import progressbar + + +def test_list() -> None: + """Progressbar can guess max_value automatically.""" + p = progressbar.ProgressBar() + for _i in p(range(10)): + time.sleep(0.001) + + +def test_iterator_with_max_value() -> None: + """Progressbar can't guess max_value.""" + p = progressbar.ProgressBar(max_value=10) + for _i in p(iter(range(10))): + time.sleep(0.001) + + +def test_iterator_without_max_value_error() -> None: + """Progressbar can't guess max_value.""" + p = progressbar.ProgressBar() + + for _i in p(iter(range(10))): + time.sleep(0.001) + + assert p.max_value is progressbar.UnknownLength + + +def test_iterator_without_max_value() -> None: + """Progressbar can't guess max_value.""" + p = progressbar.ProgressBar( + widgets=[ + progressbar.AnimatedMarker(), + progressbar.FormatLabel('%(value)d'), + progressbar.BouncingBar(), + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ], + ) + for _i in p(iter(range(10))): + time.sleep(0.001) + + +def test_iterator_with_incorrect_max_value() -> None: + """Progressbar can't guess max_value.""" + p = progressbar.ProgressBar(max_value=10) + with pytest.raises(ValueError): + for _i in p(iter(range(20))): + time.sleep(0.001) + + +def test_adding_value() -> None: + p = progressbar.ProgressBar(max_value=10) + p.start() + p.update(5) + p += 2 + p.increment(2) + with pytest.raises(ValueError): + p += 5 diff --git a/tests/test_job_status.py b/tests/test_job_status.py new file mode 100644 index 00000000..d4770908 --- /dev/null +++ b/tests/test_job_status.py @@ -0,0 +1,48 @@ +import io +import time + +import pytest + +import progressbar +from progressbar import utils + + +@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) + + +def test_job_status_bar_does_not_overflow_width() -> None: + # Regression: B4 - accumulated job markers made the rendered output + # wider than the allotted width. + widget = progressbar.widgets.JobStatusBar('status') + bar = progressbar.ProgressBar( + widgets=[widget], + variables={'status': None}, + max_value=100, + fd=io.StringIO(), + term_width=60, + ) + bar.start() + data = bar.data() + data['variables'] = {'status': True} + + width = 5 + output = '' + for _ in range(10): + output = widget(bar, data, width=width) + + assert utils.len_color(output) <= width + bar.finish(dirty=True) diff --git a/tests/test_large_values.py b/tests/test_large_values.py new file mode 100644 index 00000000..2e7ad72f --- /dev/null +++ b/tests/test_large_values.py @@ -0,0 +1,17 @@ +import time + +import progressbar + + +def test_large_max_value() -> None: + with progressbar.ProgressBar(max_value=1e10) as bar: + for i in range(10): + bar.update(i) + time.sleep(0.1) + + +def test_value_beyond_max_value() -> None: + with progressbar.ProgressBar(max_value=10, max_error=False) as bar: + for i in range(20): + bar.update(i) + time.sleep(0.01) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 00000000..b0725afe --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,13 @@ +from progressbar import __about__ + + +def test_about() -> None: + assert __about__.__title__ + assert __about__.__package_name__ + assert __about__.__author__ + assert __about__.__description__ + assert __about__.__email__ + assert __about__.__version__ + assert __about__.__license__ + assert __about__.__copyright__ + assert __about__.__url__ diff --git a/tests/test_monitor_progress.py b/tests/test_monitor_progress.py new file mode 100644 index 00000000..6f883487 --- /dev/null +++ b/tests/test_monitor_progress.py @@ -0,0 +1,289 @@ +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:16', + ' 22% (2 of 9) |## | Elapsed Time: ?:00:02 ETA: ?:00:11', + ' 33% (3 of 9) |#### | Elapsed Time: ?:00:03 ETA: ?:00:08', + ' 44% (4 of 9) |##### | Elapsed Time: ?:00:04 ETA: ?:00:06', + ' 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:18', + ' 20% (2 of 10) |# | Elapsed Time: 0:00:02 ETA: 0:00:12', + ' 30% (3 of 10) |# | Elapsed Time: 0:00:03 ETA: 0:00:09', + ' 40% (4 of 10) |## | Elapsed Time: 0:00:04 ETA: 0:00:07', + ' 50% (5 of 10) |### | Elapsed Time: 0:00:05 ETA: 0:00:06', + ' 60% (6 of 10) |### | Elapsed Time: 0:00:07 ETA: 0:00:05', + ' 70% (7 of 10) |#### | Elapsed Time: 0:00:09 ETA: 0:00:04', + ' 80% (8 of 10) |#### | Elapsed Time: 0:00:11 ETA: 0:00:03', + ' 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%|######################################################|', + ), + ) + + +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%|######################################################|', + ] + + +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%###########################|', + ] + + +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|', + ] + + +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'] * 2 + + result = testdir.runpython( + testdir.makepyfile(_create_script(enable_colors=False, **kwargs)), + ) + pprint.pprint(result.stderr.lines, width=70) + assert result.stderr.lines == ['green'] * 2 diff --git a/tests/test_multibar.py b/tests/test_multibar.py new file mode 100644 index 00000000..daf55a17 --- /dev/null +++ b/tests/test_multibar.py @@ -0,0 +1,391 @@ +import contextlib +import io +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) + + # The context manager waits for all bars to finish + bar.finish() + + 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) + + # Note: MultiBar inherits from dict, so update() would be + # dict.update and insert bogus entries; render() is intended here + multibar.render(force=True, flush=False) + multibar.render(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) + # join() waits until all bars have finished, so finish first + bar.finish() + multibar.join() + multibar.join() + multibar.render(force=True) + + +def test_multibar_instances_do_not_share_thread_state() -> None: + # Regression: D1 - thread primitives were class attributes shared + # between all MultiBar instances. + multibar_a = progressbar.MultiBar(fd=io.StringIO()) + multibar_b = progressbar.MultiBar(fd=io.StringIO()) + + assert multibar_a._thread_finished is not multibar_b._thread_finished + assert multibar_a._thread_closed is not multibar_b._thread_closed + assert multibar_a._print_lock is not multibar_b._print_lock + + +def test_multibar_stop_does_not_poison_new_instances() -> None: + # Regression: D1 - stop() set a class-level Event, killing the render + # loop of every MultiBar created afterwards. + multibar = progressbar.MultiBar(fd=io.StringIO()) + multibar.start() + multibar.stop(timeout=5) + + fresh = progressbar.MultiBar(fd=io.StringIO()) + assert not fresh._thread_finished.is_set() + + +def test_multibar_start_keeps_render_thread_alive() -> None: + # Regression: D6 - start() called _thread_closed.set() instead of + # clearing it, so an empty multibar's render thread exited before + # any bars could be added. + multibar = progressbar.MultiBar(fd=io.StringIO()) + multibar.start() + try: + assert not multibar._thread_closed.is_set() + assert multibar._thread is not None + multibar._thread.join(timeout=0.5) + assert multibar._thread.is_alive() + finally: + multibar.stop(timeout=5) + + +def test_multibar_flush_does_not_emit_nul_bytes() -> None: + # Regression: D3 - flush() truncated the buffer without seeking back, + # so later writes padded the gap with NUL characters. + fd = io.StringIO() + multibar = progressbar.MultiBar(fd=fd) + multibar.print('hello') + multibar.print('world') + + assert '\x00' not in fd.getvalue() + + +def test_multibar_prepend_and_append_label() -> None: + # Regression: D7 - the append_label branch was unreachable when + # prepend_label was enabled as well. + multibar = progressbar.MultiBar( + prepend_label=True, + append_label=True, + fd=io.StringIO(), + ) + bar = progressbar.ProgressBar( + max_value=N, + widgets=['x'], + fd=io.StringIO(), + ) + multibar['job'] = bar + multibar._label_bar(bar) + + assert str(bar.widgets[0]).startswith('job') + assert str(bar.widgets[-1]).startswith('job') + + +def test_multibar_join_timeout_keeps_thread_reference() -> None: + # Regression: D8 - join(timeout) dropped the thread reference even + # when the thread was still running. + multibar = progressbar.MultiBar(fd=io.StringIO()) + assert multibar['unfinished'] is not None # creates an unfinished bar + multibar.start() + try: + multibar.join(timeout=0.01) + assert multibar._thread is not None + assert multibar._thread.is_alive() + finally: + multibar.stop(timeout=5) + + +def test_multibar_exception_in_context_exits_promptly() -> None: + # Regression: D4 - an exception inside `with MultiBar()` hung forever + # in __exit__ because join() waited for bars that never finish. + holder: dict[str, progressbar.MultiBar] = {} + + def scenario() -> None: + multibar = holder['multibar'] = progressbar.MultiBar( + fd=io.StringIO(), + ) + # Pre-fix the event is shared class state which other tests may + # have set; post-fix this only touches this instance. + multibar._thread_finished.clear() + # The bar must exist before the render thread starts so the + # thread observes an unfinished bar. + multibar['a'].update(0) + with contextlib.suppress(RuntimeError), multibar: + raise RuntimeError('boom') + + worker = threading.Thread(target=scenario, daemon=True) + worker.start() + worker.join(timeout=5) + try: + assert not worker.is_alive(), '__exit__ hung on unfinished bars' + finally: + # Unstick the render thread regardless of the outcome + holder['multibar']._thread_finished.set() + + +def test_multibar_concurrent_mutation() -> None: + # Regression: D2 - the render thread iterated self.values() without a + # snapshot while other threads add/remove bars. + errors: list[threading.ExceptHookArgs] = [] + original_excepthook = threading.excepthook + threading.excepthook = errors.append + multibar = progressbar.MultiBar(fd=io.StringIO()) + # Pre-fix the event is shared class state which other tests may have + # set; post-fix this only touches this instance. + multibar._thread_finished.clear() + assert multibar['keep'] is not None # creates an unfinished bar + multibar.start() + try: + for i in range(300): + assert multibar[f'bar {i}'] is not None + del multibar[f'bar {i}'] + finally: + multibar.stop(timeout=5) + threading.excepthook = original_excepthook + + assert not errors + assert not multibar._thread or not multibar._thread.is_alive() diff --git a/tests/test_os_specific.py b/tests/test_os_specific.py new file mode 100644 index 00000000..92792d89 --- /dev/null +++ b/tests/test_os_specific.py @@ -0,0 +1,17 @@ +import io +import os +import sys + +import pytest + +if os.name == 'nt': + pytest.skip('POSIX-only tests', allow_module_level=True) + +from progressbar.terminal import os_specific + + +def test_getch_with_non_tty_stdin(monkeypatch) -> None: + # Regression: E6 - getch() crashed with termios.error (or + # io.UnsupportedOperation) when stdin was not a tty. + monkeypatch.setattr(sys, 'stdin', io.StringIO('x')) + assert os_specific.getch() == 'x' diff --git a/tests/test_progressbar.py b/tests/test_progressbar.py index 4d2424dc..2267b59d 100644 --- a/tests/test_progressbar.py +++ b/tests/test_progressbar.py @@ -1,5 +1,202 @@ +import contextlib +import gc +import io +import os +import signal +import sys +import time +from datetime import timedelta -# def test_examples(): -# from examples import examples -# for example in examples: -# example() +import original_examples # type: ignore +import pytest + +import progressbar +from progressbar import utils + +# Import hack to allow for parallel Tox +try: + import examples +except ImportError: + import sys + + _project_dir: str = os.path.dirname(os.path.dirname(__file__)) + sys.path.append(_project_dir) + import examples + + sys.path.remove(_project_dir) + + +def test_examples(monkeypatch) -> None: + for example in examples.examples: + with contextlib.suppress(ValueError): + example() + + +@pytest.mark.filterwarnings('ignore:.*maxval.*:DeprecationWarning') +@pytest.mark.parametrize('example', original_examples.examples) +def test_original_examples(example, monkeypatch) -> None: + monkeypatch.setattr(progressbar.ProgressBar, '_MINIMUM_UPDATE_INTERVAL', 1) + monkeypatch.setattr(time, 'sleep', lambda t: None) + example() + + +@pytest.mark.parametrize('example', examples.examples) +def test_examples_nullbar(monkeypatch, example) -> None: + # Patch progressbar to use null bar instead of regular progress bar + monkeypatch.setattr(progressbar, 'ProgressBar', progressbar.NullBar) + assert progressbar.ProgressBar._MINIMUM_UPDATE_INTERVAL < 0.0001 + example() + + +def test_reuse() -> None: + bar = progressbar.ProgressBar() + bar.start() + for i in range(10): + bar.update(i) + bar.finish() + + bar.start(init=True) + for i in range(10): + bar.update(i) + bar.finish() + + bar.start(init=False) + for i in range(10): + bar.update(i) + bar.finish() + + +def test_dirty() -> None: + bar = progressbar.ProgressBar() + bar.start() + assert bar.started() + for i in range(10): + bar.update(i) + bar.finish(dirty=True) + assert bar.finished() + assert bar.started() + + +def test_negative_maximum() -> None: + with ( + pytest.raises(ValueError), + progressbar.ProgressBar(max_value=-1) as progress, + ): + progress.start() + + +def test_elapsed_data_spans_days() -> None: + # Regression: A1 - days_elapsed was computed from timedelta.seconds, + # which only contains the sub-day component. + bar = progressbar.ProgressBar( + max_value=10, fd=io.StringIO(), term_width=60 + ) + bar.start() + bar.start_time -= timedelta(days=2, hours=3, minutes=4) + data = bar.data() + + expected_days = 2 + (3 * 3600 + 4 * 60) / 86400 + assert data['days_elapsed'] == pytest.approx(expected_days, abs=0.01) + + +def test_restart_after_finish_writes_final_newline() -> None: + # Regression: A2 - init() did not reset _finished, so a reused bar + # never wrote its final newline (and never flushed) again. + bar = progressbar.ProgressBar( + max_value=5, fd=io.StringIO(), term_width=60, line_breaks=False + ) + bar.start() + bar.update(5) + bar.finish() + assert bar.fd.getvalue().endswith('\n') + + bar.fd = io.StringIO() + bar.start() + assert not bar._finished + bar.update(5) + bar.finish() + assert bar.fd.getvalue().endswith('\n') + + +def test_repeated_finish_keeps_capturing_balanced() -> None: + # Regression: A2 - every finish() call decremented the global + # capturing counter, even when the bar was already finished. + baseline = utils.streams.capturing + try: + bar = progressbar.ProgressBar( + max_value=5, fd=io.StringIO(), term_width=60 + ) + bar.start() + bar.update(5) + bar.finish() + bar.finish() + assert utils.streams.capturing == baseline + finally: + utils.streams.capturing = baseline + + +def test_del_suppresses_finish_errors(monkeypatch) -> None: + # Regression: A4 - __del__ only suppressed AttributeError; any other + # exception from finish() leaked out of the finalizer (reported via + # sys.unraisablehook during garbage collection). + class ExplodingIO(io.StringIO): + def write(self, value: str) -> int: + raise ValueError('I/O operation on closed file') + + unraisable: list[object] = [] + monkeypatch.setattr(sys, 'unraisablehook', unraisable.append) + + bar = progressbar.ProgressBar(max_value=5, fd=io.StringIO(), term_width=60) + bar.start() + bar.fd = ExplodingIO() + del bar + gc.collect() + + assert not unraisable + + +@pytest.mark.skipif(os.name == 'nt', reason='SIGWINCH is POSIX-only') +def test_sigwinch_restored_with_overlapping_bars() -> None: + # Regression: A5 - with two live bars, finishing them in creation + # order left a dangling handler installed. + from progressbar.bar import _ResizeRegistry + + saved_handler = signal.getsignal(signal.SIGWINCH) + # Isolate the global registry so the assertions don't depend on bars + # left registered (and a handler left installed) by other tests + saved_bars = list(_ResizeRegistry.bars) + saved_prev = _ResizeRegistry.previous_handler + _ResizeRegistry.bars.clear() + _ResizeRegistry.previous_handler = None + + # Start from a known sentinel handler so we can tell apart "still + # installed" from "restored" without depending on global state + signal.signal(signal.SIGWINCH, signal.SIG_IGN) + try: + bar1 = progressbar.ProgressBar(max_value=5, fd=io.StringIO()) + bar1.start() + bar2 = progressbar.ProgressBar(max_value=5, fd=io.StringIO()) + bar2.start() + + # The first bar installs the shared handler + assert signal.getsignal(signal.SIGWINCH) is not signal.SIG_IGN + + # A resize signal is dispatched to all live bars + signal.raise_signal(signal.SIGWINCH) + assert isinstance(bar1.term_width, int) + assert isinstance(bar2.term_width, int) + + bar1.update(5) + bar1.finish() + # The handler must stay installed while bar2 is still live + assert signal.getsignal(signal.SIGWINCH) is not signal.SIG_IGN + + bar2.update(5) + bar2.finish() + # The last bar to finish restores the previous handler + assert signal.getsignal(signal.SIGWINCH) is signal.SIG_IGN + finally: + for restored_bar in saved_bars: + _ResizeRegistry.bars.add(restored_bar) + _ResizeRegistry.previous_handler = saved_prev + signal.signal(signal.SIGWINCH, saved_handler) diff --git a/tests/test_progressbar_command.py b/tests/test_progressbar_command.py new file mode 100644 index 00000000..a277f8ca --- /dev/null +++ b/tests/test_progressbar_command.py @@ -0,0 +1,208 @@ +import io + +import pytest + +import progressbar +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')]) + + +@pytest.fixture +def recorded_bars(monkeypatch): + created = [] + + class RecordingProgressBar(progressbar.ProgressBar): + def __init__(self, **kwargs) -> None: + created.append(self) + self.init_kwargs = kwargs + super().__init__(**kwargs) + + monkeypatch.setattr(main.progressbar, 'ProgressBar', RecordingProgressBar) + return created + + +def test_main_passes_widgets(tmp_path, recorded_bars) -> None: + # Regression: E2 - the configured widgets were built but never passed + # to the progress bar. + file = tmp_path / 'data.bin' + file.write_bytes(b'x' * 1024) + main.main([str(file), '-o', str(tmp_path / 'out.bin')]) + + assert recorded_bars + assert recorded_bars[0].init_kwargs.get('widgets') + + +def test_main_line_mode_counts_bytes(tmp_path, recorded_bars) -> None: + # Regression: E1 - line mode counted characters while the maximum was + # measured in bytes, so multi-byte content never reached 100%. + file = tmp_path / 'data.txt' + file.write_text(('é' * 99 + '\n') * 5, encoding='utf-8') + size = file.stat().st_size + + main.main(['-l', str(file), '-o', str(tmp_path / 'out.txt')]) + + assert recorded_bars[0].value == size + + +def test_main_broken_pipe(tmp_path, monkeypatch) -> None: + # Regression: E3 - an early-closing downstream pipe raised an + # unhandled BrokenPipeError. + file = tmp_path / 'data.bin' + file.write_bytes(b'x' * 1024) + + class BrokenPipeIO(io.BytesIO): + def write(self, data) -> int: + raise BrokenPipeError + + monkeypatch.setattr( + main, '_get_output_stream', lambda *args: BrokenPipeIO() + ) + main.main([str(file)]) # must not raise + + +def test_main_empty_file_has_known_size(tmp_path, recorded_bars) -> None: + # Regression: E8 - a zero-byte input flipped the bar into + # unknown-length mode although the file size was known. + file = tmp_path / 'empty.bin' + file.write_bytes(b'') + main.main([str(file), '-o', str(tmp_path / 'out.bin')]) + + assert recorded_bars[0].init_kwargs.get('max_value') == 0 diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 00000000..36ce4ad5 --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,129 @@ +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] + + +def test_timedelta_samples_evicted_when_value_stalls() -> None: + # Regression: B7 - eviction of expired samples additionally required + # the value to increase, so a stalled bar grew its window unboundedly. + samples_widget = widgets.SamplesMixin(samples=timedelta(seconds=2)) + bar = progressbar.ProgressBar(widgets=[samples_widget]) + samples_widget.INTERVAL = timedelta(0) + start = datetime(2000, 1, 1) + + bar.value = 1 + for i in range(10): + bar.last_update_time = start + timedelta(seconds=i) + samples_widget(bar, None) + + sample_times = samples_widget.get_sample_times(bar, None) + assert sample_times[-1] - sample_times[0] <= timedelta(seconds=3) diff --git a/tests/test_speed.py b/tests/test_speed.py new file mode 100644 index 00000000..928b9d0c --- /dev/null +++ b/tests/test_speed.py @@ -0,0 +1,38 @@ +import pytest + +import progressbar + + +@pytest.mark.parametrize( + 'total_seconds_elapsed,value,expected', + [ + # Zero progress means no data yet, so the regular format is used + # instead of the inverse (seconds per unit) format + (1, 0, ' 0.0 B/s'), + (1, 0.01, '100.0 s/B'), + (1, 0.1, ' 0.1 B/s'), + (1, 1, ' 1.0 B/s'), + (1, 2**10 - 1, '1023.0 B/s'), + (1, 2**10 + 0, ' 1.0 KiB/s'), + (1, 2**20, ' 1.0 MiB/s'), + (1, 2**30, ' 1.0 GiB/s'), + (1, 2**40, ' 1.0 TiB/s'), + (1, 2**50, ' 1.0 PiB/s'), + (1, 2**60, ' 1.0 EiB/s'), + (1, 2**70, ' 1.0 ZiB/s'), + (1, 2**80, ' 1.0 YiB/s'), + (1, 2**90, '1024.0 YiB/s'), + ], +) +def test_file_transfer_speed(total_seconds_elapsed, value, expected) -> None: + widget = progressbar.FileTransferSpeed() + assert ( + widget( + None, + dict( + total_seconds_elapsed=total_seconds_elapsed, + value=value, + ), + ) + == expected + ) diff --git a/tests/test_stream.py b/tests/test_stream.py new file mode 100644 index 00000000..194310c2 --- /dev/null +++ b/tests/test_stream.py @@ -0,0 +1,183 @@ +import io +import os +import sys + +import pytest + +import progressbar +from progressbar import terminal + + +def test_nowrap() -> None: + # Make sure we definitely unwrap + for _i in range(5): + progressbar.streams.unwrap(stderr=True, stdout=True) + + stdout = sys.stdout + stderr = sys.stderr + + progressbar.streams.wrap() + + assert stdout == sys.stdout + assert stderr == sys.stderr + + progressbar.streams.unwrap() + + assert stdout == sys.stdout + assert stderr == sys.stderr + + # Make sure we definitely unwrap + for _i in range(5): + progressbar.streams.unwrap(stderr=True, stdout=True) + + +def test_wrap() -> None: + # Make sure we definitely unwrap + for _i in range(5): + progressbar.streams.unwrap(stderr=True, stdout=True) + + stdout = sys.stdout + stderr = sys.stderr + + progressbar.streams.wrap(stderr=True, stdout=True) + + assert stdout != sys.stdout + assert stderr != sys.stderr + + # Wrap again + stdout = sys.stdout + stderr = sys.stderr + + progressbar.streams.wrap(stderr=True, stdout=True) + + assert stdout == sys.stdout + assert stderr == sys.stderr + + # Make sure we definitely unwrap + for _i in range(5): + progressbar.streams.unwrap(stderr=True, stdout=True) + + +def test_excepthook() -> None: + progressbar.streams.wrap(stderr=True, stdout=True) + + try: + raise RuntimeError() # noqa: TRY301 + except RuntimeError: + progressbar.streams.excepthook(*sys.exc_info()) + + progressbar.streams.unwrap_excepthook() + progressbar.streams.unwrap_excepthook() + + +def test_fd_as_io_stream() -> None: + stream = io.StringIO() + with progressbar.ProgressBar(fd=stream) as pb: + for i in range(101): + pb.update(i) + stream.close() + + +def test_no_newlines() -> None: + kwargs = dict( + redirect_stderr=True, + redirect_stdout=True, + line_breaks=False, + is_terminal=True, + ) + + with progressbar.ProgressBar(**kwargs) as bar: + for i in range(5): + bar.update(i) + + for i in range(5, 10): + try: + print('\n\n', file=progressbar.streams.stdout) + print('\n\n', file=progressbar.streams.stderr) + except ValueError: + pass + bar.update(i) + + +def test_update_keeps_colors_when_enabled() -> None: + stream = io.StringIO() + with progressbar.ProgressBar( + fd=stream, + widgets=['\033[92mgreen\033[0m'], + max_value=1, + enable_colors=True, + ) as bar: + bar.update(1) + + assert '\033[92mgreen\033[0m' in stream.getvalue() + + +@pytest.mark.parametrize('stream', [sys.__stdout__, sys.__stderr__]) +@pytest.mark.skipif(os.name == 'nt', reason='Windows does not support this') +def test_fd_as_standard_streams(stream) -> None: + with progressbar.ProgressBar(fd=stream) as pb: + for i in range(101): + pb.update(i) + + +def test_line_offset_stream_wrapper() -> None: + stream = terminal.LineOffsetStreamWrapper(5, io.StringIO()) + stream.write('Hello World!') + + +def test_last_line_stream_methods() -> None: + stream = terminal.LastLineStream(io.StringIO()) + + # Test write method + stream.write('Hello World!') + assert stream.read() == 'Hello World!' + assert stream.read(5) == 'Hello' + + # Test flush method + stream.flush() + assert stream.line == 'Hello World!' + assert stream.readline() == 'Hello World!' + assert stream.readline(5) == 'Hello' + + # Test truncate method + stream.truncate(5) + assert stream.line == 'Hello' + stream.truncate() + assert stream.line == '' + + # Test seekable/readable + assert not stream.seekable() + assert stream.readable() + + stream.writelines(['a', 'b', 'c']) + assert stream.read() == 'c' + + assert list(stream) == ['c'] + + with stream: + stream.write('Hello World!') + assert stream.read() == 'Hello World!' + assert stream.read(5) == 'Hello' + + # Test close method + stream.close() + + +def test_line_offset_stream_wrapper_write_length_and_flush() -> None: + # Regression: C5/C6 - write() returned the newline-stripped length + # and flush() never reached the wrapped stream. + class CountingIO(io.StringIO): + def __init__(self) -> None: + super().__init__() + self.flushes = 0 + + def flush(self) -> None: + self.flushes += 1 + super().flush() + + target = CountingIO() + wrapper = progressbar.LineOffsetStreamWrapper(lines=2, stream=target) + + written = wrapper.write('hello\n') + assert written == 6 + assert target.flushes >= 1 diff --git a/tests/test_terminal.py b/tests/test_terminal.py new file mode 100644 index 00000000..77815a41 --- /dev/null +++ b/tests/test_terminal.py @@ -0,0 +1,224 @@ +import io +import signal +import sys +import time +from datetime import timedelta + +import progressbar +from progressbar import terminal + + +def test_left_justify() -> None: + """Left justify using the terminal width""" + p = progressbar.ProgressBar( + widgets=[progressbar.BouncingBar(marker=progressbar.RotatingMarker())], + max_value=100, + term_width=20, + left_justify=True, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + + +def test_right_justify() -> None: + """Right justify using the terminal width""" + p = progressbar.ProgressBar( + widgets=[progressbar.BouncingBar(marker=progressbar.RotatingMarker())], + max_value=100, + term_width=20, + left_justify=False, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + + +def test_auto_width(monkeypatch) -> None: + """Right justify using the terminal width""" + + def ioctl(*args): + return '\xbf\x00\xeb\x00\x00\x00\x00\x00' + + def fake_signal(signal, func): + pass + + try: + import fcntl + + monkeypatch.setattr(fcntl, 'ioctl', ioctl) + monkeypatch.setattr(signal, 'signal', fake_signal) + p = progressbar.ProgressBar( + widgets=[ + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ], + max_value=100, + left_justify=True, + term_width=None, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + except ImportError: + pass # Skip on Windows + + +def test_fill_right() -> None: + """Right justify using the terminal width""" + p = progressbar.ProgressBar( + widgets=[progressbar.BouncingBar(fill_left=False)], + max_value=100, + term_width=20, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + + +def test_fill_left() -> None: + """Right justify using the terminal width""" + p = progressbar.ProgressBar( + widgets=[progressbar.BouncingBar(fill_left=True)], + max_value=100, + term_width=20, + ) + + assert p.term_width is not None + for i in range(100): + p.update(i) + + +def test_no_fill(monkeypatch) -> None: + """Simply bounce within the terminal width""" + bar = progressbar.BouncingBar() + bar.INTERVAL = timedelta(seconds=1) + p = progressbar.ProgressBar( + widgets=[bar], + max_value=progressbar.UnknownLength, + term_width=20, + ) + + assert p.term_width is not None + for i in range(30): + p.update(i, force=True) + # Fake the start time so we can actually emulate a moving progress bar + p.start_time = p.start_time - timedelta(seconds=i) + + +def test_stdout_redirection() -> None: + p = progressbar.ProgressBar( + fd=sys.stdout, + max_value=10, + redirect_stdout=True, + ) + + for i in range(10): + print('', file=sys.stdout) + p.update(i) + + +def test_double_stdout_redirection() -> None: + p = progressbar.ProgressBar(max_value=10, redirect_stdout=True) + p2 = progressbar.ProgressBar(max_value=10, redirect_stdout=True) + + for i in range(10): + print('', file=sys.stdout) + p.update(i) + p2.update(i) + + +def test_stderr_redirection() -> None: + p = progressbar.ProgressBar(max_value=10, redirect_stderr=True) + + for i in range(10): + print('', file=sys.stderr) + p.update(i) + + +def test_stdout_stderr_redirection() -> None: + p = progressbar.ProgressBar( + max_value=10, + redirect_stdout=True, + redirect_stderr=True, + ) + p.start() + + for i in range(10): + time.sleep(0.01) + print('', file=sys.stdout) + print('', file=sys.stderr) + p.update(i) + + p.finish() + + +def test_resize(monkeypatch) -> None: + def ioctl(*args): + return '\xbf\x00\xeb\x00\x00\x00\x00\x00' + + def fake_signal(signal, func): + pass + + try: + import fcntl + + monkeypatch.setattr(fcntl, 'ioctl', ioctl) + monkeypatch.setattr(signal, 'signal', fake_signal) + + p = progressbar.ProgressBar(max_value=10) + p.start() + + for i in range(10): + p.update(i) + p._handle_resize() + + p.finish() + except ImportError: + pass # Skip on Windows + + +def test_base() -> None: + assert str(terminal.CUP) + assert str(terminal.CLEAR_SCREEN_ALL_AND_HISTORY) + + terminal.clear_line(0) + terminal.clear_line(1) + + +def _redirect_update_output(*, redirect_blank_line: bool) -> str: + # Return only what a single update() writes while redirected output is + # pending a clear (the bar otherwise uses '\r', never '\n'). + fd = io.StringIO() + bar = progressbar.ProgressBar( + max_value=10, + fd=fd, + redirect_blank_line=redirect_blank_line, + is_terminal=True, + term_width=40, + ) + bar.start() + fd.seek(0) + fd.truncate(0) # drop the start frame + bar.update(5, force=True) + return fd.getvalue() + + +def test_redirect_blank_line_separator(monkeypatch) -> None: + # #295: opt-in blank line between redirected output and the bar. Force + # `needs_clear` so the test does not depend on global stream state. + from progressbar import utils + + monkeypatch.setattr(utils.streams, 'needs_clear', lambda: True) + assert '\n' in _redirect_update_output(redirect_blank_line=True) + + +def test_redirect_blank_line_off_by_default(monkeypatch) -> None: + # Default behaviour is unchanged: no separator even with output pending. + from progressbar import utils + + monkeypatch.setattr(utils.streams, 'needs_clear', lambda: True) + assert '\n' not in _redirect_update_output(redirect_blank_line=False) diff --git a/tests/test_timed.py b/tests/test_timed.py new file mode 100644 index 00000000..ee19ab95 --- /dev/null +++ b/tests/test_timed.py @@ -0,0 +1,187 @@ +import datetime +import time + +import progressbar + + +def test_timer() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.Timer(), + ] + p = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + p.update() + p.update(1) + p._needs_update() + time.sleep(0.001) + p.update(1) + p.finish() + + +def test_eta() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.ETA(), + ] + p = progressbar.ProgressBar( + min_value=0, + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + time.sleep(0.001) + p.update(0) + time.sleep(0.001) + p.update(1) + time.sleep(0.001) + p.update(1) + time.sleep(0.001) + p.update(2) + time.sleep(0.001) + p.finish() + time.sleep(0.001) + p.update(2) + + +def test_adaptive_eta() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.AdaptiveETA(), + ] + widgets[0].INTERVAL = datetime.timedelta(microseconds=1) + p = progressbar.ProgressBar( + max_value=2, + samples=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + for _i in range(20): + p.update(1) + time.sleep(0.001) + p.finish() + + +def test_adaptive_transfer_speed() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.AdaptiveTransferSpeed(), + ] + p = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + p.update(1) + time.sleep(0.001) + p.update(1) + p.finish() + + +def test_etas(monkeypatch) -> None: + """Compare file transfer speed to adaptive transfer speed""" + n = 10 + interval = datetime.timedelta(seconds=1) + widgets = [ + progressbar.FileTransferSpeed(), + progressbar.AdaptiveTransferSpeed(samples=n / 2), + ] + + datas = [] + + # Capture the output sent towards the `_speed` method + def calculate_eta(self, value, elapsed): + """Capture the widget output""" + data = dict( + value=value, + elapsed=int(elapsed), + ) + datas.append(data) + return 0, 0 + + monkeypatch.setattr(progressbar.FileTransferSpeed, '_speed', calculate_eta) + monkeypatch.setattr( + progressbar.AdaptiveTransferSpeed, + '_speed', + calculate_eta, + ) + + for widget in widgets: + widget.INTERVAL = interval + + p = progressbar.ProgressBar( + max_value=n, + widgets=widgets, + poll_interval=interval, + ) + + # Run the first few samples at a low speed and speed up later so we can + # compare the results from both widgets + for i in range(n): + p.update(i) + if i > n / 2: + time.sleep(1) + else: + time.sleep(10) + p.finish() + + # Due to weird travis issues, the actual testing is disabled for now + # import pprint + # pprint.pprint(datas[::2]) + # pprint.pprint(datas[1::2]) + + # for i, (a, b) in enumerate(zip(datas[::2], datas[1::2])): + # # Because the speed is identical initially, the results should be the + # # same for adaptive and regular transfer speed. Only when the speed + # # changes we should start see a lot of differences between the two + # if i < (n / 2 - 1): + # assert a['elapsed'] == b['elapsed'] + # if i > (n / 2 + 1): + # assert a['elapsed'] > b['elapsed'] + + +def test_non_changing_eta() -> None: + """Testing (Adaptive)ETA when the value doesn't actually change""" + widgets = [ + progressbar.AdaptiveETA(), + progressbar.ETA(), + progressbar.AdaptiveTransferSpeed(), + ] + p = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) + + p.start() + p.update(1) + time.sleep(0.001) + p.update(1) + p.finish() + + +def test_eta_not_available(): + """ + When ETA is not available (data coming from a generator), + ETAs should not raise exceptions. + """ + + def gen(): + yield from range(200) + + widgets = [progressbar.AdaptiveETA(), progressbar.ETA()] + + bar = progressbar.ProgressBar(widgets=widgets) + for _i in bar(gen()): + pass diff --git a/tests/test_timer.py b/tests/test_timer.py new file mode 100644 index 00000000..083e1b18 --- /dev/null +++ b/tests/test_timer.py @@ -0,0 +1,60 @@ +from datetime import timedelta + +import pytest + +import progressbar + + +@pytest.mark.parametrize( + 'poll_interval,expected', + [ + (1, 1), + (timedelta(seconds=1), 1), + (0.001, 0.001), + (timedelta(microseconds=1000), 0.001), + ], +) +@pytest.mark.parametrize( + 'parameter', + [ + 'poll_interval', + 'min_poll_interval', + ], +) +def test_poll_interval(parameter, poll_interval, expected) -> None: + # Test int, float and timedelta intervals + bar = progressbar.ProgressBar(**{parameter: poll_interval}) + assert getattr(bar, parameter) == expected + + +@pytest.mark.parametrize( + 'interval', + [ + 1, + timedelta(seconds=1), + ], +) +def test_intervals(monkeypatch, interval) -> None: + monkeypatch.setattr( + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + interval, + ) + bar = progressbar.ProgressBar(max_value=100) + + # Initially there should be no last_update_time + assert bar.last_update_time is None + + # After updating there should be a last_update_time + bar.update(1) + assert bar.last_update_time + + # We should not need an update if the time is nearly the same as before + last_update_time = bar.last_update_time + bar.update(2) + assert bar.last_update_time == last_update_time + + # We should need an update if we're beyond the poll_interval + bar._last_update_time -= 2 + bar.update(3) + assert bar.last_update_time != last_update_time diff --git a/tests/test_unicode.py b/tests/test_unicode.py new file mode 100644 index 00000000..3babbddd --- /dev/null +++ b/tests/test_unicode.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import time + +import pytest +from python_utils import converters + +import progressbar + + +@pytest.mark.parametrize( + 'name,markers', + [ + ('line arrows', '←↖↑↗→↘↓↙'), + ('block arrows', '◢◣◤◥'), + ('wheels', '◐◓◑◒'), + ], +) +@pytest.mark.parametrize('as_unicode', [True, False]) +def test_markers(name, markers: bytes | str, as_unicode) -> None: + if as_unicode: + markers = converters.to_unicode(markers) + else: + markers = converters.to_str(markers) + + widgets = [ + f'{name.capitalize()}: ', + progressbar.AnimatedMarker(markers=markers), + ] + bar = progressbar.ProgressBar(widgets=widgets) + bar._MINIMUM_UPDATE_INTERVAL = 1e-12 + for _i in bar(iter(range(24))): + time.sleep(0.001) diff --git a/tests/test_unknown_length.py b/tests/test_unknown_length.py new file mode 100644 index 00000000..81a1c319 --- /dev/null +++ b/tests/test_unknown_length.py @@ -0,0 +1,50 @@ +import progressbar + + +def test_unknown_length() -> None: + pb = progressbar.ProgressBar( + widgets=[progressbar.AnimatedMarker()], + max_value=progressbar.UnknownLength, + ) + assert pb.max_value is progressbar.UnknownLength + + +def test_unknown_length_default_widgets() -> None: + # The default widgets picked should work without a known max_value + pb = progressbar.ProgressBar(max_value=progressbar.UnknownLength).start() + for i in range(60): + pb.update(i) + pb.finish() + + +def test_unknown_length_at_start() -> None: + # The default widgets should be picked after we call .start() + pb = progressbar.ProgressBar().start(max_value=progressbar.UnknownLength) + for i in range(60): + pb.update(i) + pb.finish() + + pb2 = progressbar.ProgressBar().start(max_value=progressbar.UnknownLength) + for w in pb2.widgets: + print(type(w), repr(w)) + assert any(isinstance(w, progressbar.Bar) for w in pb2.widgets) + + +def test_unknown_length_redraws_on_value_change() -> None: + # With an unknown length and a non-time-sensitive widget (no + # `INTERVAL`), the bar still needs to redraw whenever the value + # advances; otherwise it would only ever show the start and finish + # values. See the `format_label` example. + pb = progressbar.ProgressBar( + widgets=[progressbar.FormatLabel('%(value)d')], + max_value=progressbar.UnknownLength, + ).start() + + assert pb.poll_interval is None + pb.previous_value = 2 + pb.value = 3 + # Make sure the min_poll_interval rate limit is not what blocks us + pb._last_update_timer -= 10 + assert pb._needs_update() is True + + pb.finish() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..fd8ab866 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,204 @@ +import contextlib +import io +import sys + +import pytest + +import progressbar +import progressbar.env +from progressbar import utils + + +@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) + + +def test_stream_wrapper_unwrap_restores_excepthook() -> None: + # Regression: C7 - unwrap_stdout/unwrap_stderr left the custom + # excepthook installed forever. + wrapper = utils.StreamWrapper() + hook_before = sys.excepthook + wrapper.wrap_stdout() + try: + wrapper.unwrap_stdout() + assert sys.excepthook is hook_before + + # With both streams wrapped, the hook is only restored once the + # last stream is unwrapped + wrapper.wrap_stdout() + wrapper.wrap_stderr() + wrapper.unwrap_stdout() + # Bound methods are recreated on attribute access, so compare + # with == instead of `is` + assert sys.excepthook == wrapper.excepthook + wrapper.unwrap_stderr() + assert sys.excepthook is hook_before + + # Same in reverse order: stderr first, then stdout + wrapper.wrap_stdout() + wrapper.wrap_stderr() + wrapper.unwrap_stderr() + assert sys.excepthook == wrapper.excepthook + wrapper.unwrap_stdout() + assert sys.excepthook is hook_before + finally: + sys.excepthook = wrapper.original_excepthook + sys.stdout = wrapper.original_stdout + sys.stderr = wrapper.original_stderr + + +def test_stream_wrapper_flush_unsupported_keeps_int_counter() -> None: + # Regression: C2 - the unsupported-operation handler assigned False + # to the int wrap counter. + class UnsupportedIO(io.StringIO): + def write(self, value: str) -> int: + raise io.UnsupportedOperation('write') + + wrapper = utils.StreamWrapper() + wrapper.stdout = utils.WrappingIO(UnsupportedIO()) + wrapper.stdout.buffer.write('x') + wrapper.wrapped_stdout = 1 + wrapper.flush() + + assert wrapper.wrapped_stdout == 0 + assert type(wrapper.wrapped_stdout) is int + + +def test_wrapping_io_flush_does_not_duplicate_after_error() -> None: + # Regression: C3 - a failed target.write() left the buffer intact, so + # the next flush wrote the same data again. + class FlakyIO(io.StringIO): + def __init__(self) -> None: + super().__init__() + self.fail_once = True + + def write(self, value: str) -> int: + result = super().write(value) + if self.fail_once: + self.fail_once = False + raise OSError('disk full') + return result + + target = FlakyIO() + wrapped = utils.WrappingIO(target) + wrapped.buffer.write('hello') + with pytest.raises(OSError): + wrapped._flush() + with contextlib.suppress(OSError): + wrapped._flush() + + assert target.getvalue().count('hello') == 1 + + +def test_wrapping_io_flush_with_closed_target() -> None: + # Regression: C4 - flushing into an already closed target (e.g. from + # the atexit hook at interpreter shutdown) raised ValueError. + target = io.StringIO() + wrapped = utils.WrappingIO(target) + wrapped.buffer.write('data') + target.close() + wrapped._flush() # must not raise diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 00000000..af4f1a33 --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import io +import time +from datetime import timedelta + +import pytest + +import progressbar + +max_values: list[None | type[progressbar.base.UnknownLength] | int] = [ + None, + 10, + progressbar.UnknownLength, +] + + +def test_create_wrapper() -> None: + with pytest.raises(AssertionError): + progressbar.widgets.create_wrapper('ab') + + with pytest.raises(RuntimeError): + progressbar.widgets.create_wrapper(123) + + +def test_widgets_small_values() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.AbsoluteETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + p = progressbar.ProgressBar(widgets=widgets, max_value=10).start() + p.update(0) + for i in range(10): + time.sleep(1) + p.update(i + 1) + p.finish() + + +@pytest.mark.parametrize('max_value', [10**6, 10**8]) +def test_widgets_large_values(max_value) -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.AbsoluteETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + p = progressbar.ProgressBar(widgets=widgets, max_value=max_value).start() + for i in range(0, 10**6, 10**4): + time.sleep(1) + p.update(i + 1) + p.finish() + + +def test_format_widget() -> None: + widgets = [ + progressbar.FormatLabel(f'%({mapping})r') + for mapping in progressbar.FormatLabel.mapping + ] + p = progressbar.ProgressBar(widgets=widgets) + for _ in p(range(10)): + time.sleep(1) + + +@pytest.mark.parametrize('max_value', [None, 10]) +def test_all_widgets_small_values(max_value) -> None: + widgets = [ + progressbar.Timer(), + progressbar.ETA(), + progressbar.AdaptiveETA(), + progressbar.AbsoluteETA(), + progressbar.DataSize(), + progressbar.FileTransferSpeed(), + progressbar.AdaptiveTransferSpeed(), + progressbar.AnimatedMarker(), + progressbar.Counter(), + progressbar.Percentage(), + progressbar.FormatLabel('%(value)d'), + progressbar.SimpleProgress(), + progressbar.Bar(), + progressbar.ReverseBar(), + progressbar.BouncingBar(), + progressbar.CurrentTime(), + progressbar.CurrentTime(microseconds=False), + progressbar.CurrentTime(microseconds=True), + ] + p = progressbar.ProgressBar(widgets=widgets, max_value=max_value) + for i in range(10): + time.sleep(1) + p.update(i + 1) + p.finish() + + +@pytest.mark.parametrize('max_value', [10**6, 10**7]) +def test_all_widgets_large_values(max_value) -> None: + widgets = [ + progressbar.Timer(), + progressbar.ETA(), + progressbar.AdaptiveETA(), + progressbar.AbsoluteETA(), + progressbar.DataSize(), + progressbar.FileTransferSpeed(), + progressbar.AdaptiveTransferSpeed(), + progressbar.AnimatedMarker(), + progressbar.Counter(), + progressbar.Percentage(), + progressbar.FormatLabel('%(value)d/%(max_value)d'), + progressbar.SimpleProgress(), + progressbar.Bar(fill=lambda progress, data, width: '#'), + progressbar.ReverseBar(), + progressbar.BouncingBar(), + progressbar.FormatCustomText('Custom %(text)s', dict(text='text')), + ] + p = progressbar.ProgressBar(widgets=widgets, max_value=max_value) + p.update() + time.sleep(1) + p.update() + + for i in range(0, 10**6, 10**4): + time.sleep(1) + p.update(i) + + +@pytest.mark.parametrize('min_width', [None, 1, 2, 80, 120]) +@pytest.mark.parametrize('term_width', [1, 2, 80, 120]) +def test_all_widgets_min_width(min_width, term_width) -> None: + widgets = [ + progressbar.Timer(min_width=min_width), + progressbar.ETA(min_width=min_width), + progressbar.AdaptiveETA(min_width=min_width), + progressbar.AbsoluteETA(min_width=min_width), + progressbar.DataSize(min_width=min_width), + progressbar.FileTransferSpeed(min_width=min_width), + progressbar.AdaptiveTransferSpeed(min_width=min_width), + progressbar.AnimatedMarker(min_width=min_width), + progressbar.Counter(min_width=min_width), + progressbar.Percentage(min_width=min_width), + progressbar.FormatLabel('%(value)d', min_width=min_width), + progressbar.SimpleProgress(min_width=min_width), + progressbar.Bar(min_width=min_width), + progressbar.ReverseBar(min_width=min_width), + progressbar.BouncingBar(min_width=min_width), + progressbar.FormatCustomText( + 'Custom %(text)s', + dict(text='text'), + min_width=min_width, + ), + progressbar.DynamicMessage('custom', min_width=min_width), + progressbar.CurrentTime(min_width=min_width), + ] + p = progressbar.ProgressBar(widgets=widgets, term_width=term_width) + p.update(0) + p.update() + for widget in p._format_widgets(): + if min_width and min_width > term_width: + assert widget == '' + else: + assert widget != '' + + +@pytest.mark.parametrize('max_width', [None, 1, 2, 80, 120]) +@pytest.mark.parametrize('term_width', [1, 2, 80, 120]) +def test_all_widgets_max_width(max_width, term_width) -> None: + widgets = [ + progressbar.Timer(max_width=max_width), + progressbar.ETA(max_width=max_width), + progressbar.AdaptiveETA(max_width=max_width), + progressbar.AbsoluteETA(max_width=max_width), + progressbar.DataSize(max_width=max_width), + progressbar.FileTransferSpeed(max_width=max_width), + progressbar.AdaptiveTransferSpeed(max_width=max_width), + progressbar.AnimatedMarker(max_width=max_width), + progressbar.Counter(max_width=max_width), + progressbar.Percentage(max_width=max_width), + progressbar.FormatLabel('%(value)d', max_width=max_width), + progressbar.SimpleProgress(max_width=max_width), + progressbar.Bar(max_width=max_width), + progressbar.ReverseBar(max_width=max_width), + progressbar.BouncingBar(max_width=max_width), + progressbar.FormatCustomText( + 'Custom %(text)s', + dict(text='text'), + max_width=max_width, + ), + progressbar.DynamicMessage('custom', max_width=max_width), + progressbar.CurrentTime(max_width=max_width), + ] + p = progressbar.ProgressBar(widgets=widgets, term_width=term_width) + p.update(0) + p.update() + for widget in p._format_widgets(): + if max_width and max_width < term_width: + assert widget == '' + else: + assert widget != '' + + +def test_eta_respects_min_value() -> None: + # Regression: B3 - the items/second rate divided by the raw value + # instead of the progress relative to min_value. + bar = progressbar.ProgressBar( + min_value=50, max_value=100, fd=io.StringIO(), term_width=60 + ) + bar.start() + bar.update(75) + bar.start_time -= timedelta(seconds=30) + data = bar.data() + progressbar.ETA()(bar, data) + + # 25 of 50 items done in 30 seconds -> 30 seconds remaining + assert data['eta_seconds'] == pytest.approx(30, rel=0.05) + + +def test_multi_progress_bar_zero_total() -> None: + # Regression: B5 - a (value, 0) tuple raised ZeroDivisionError. + widget = progressbar.MultiProgressBar('jobs') + bar = progressbar.ProgressBar( + widgets=[widget], max_value=10, fd=io.StringIO(), term_width=60 + ) + ranges = widget.get_values(bar, {'variables': {'jobs': [(3, 0)]}}) + assert sum(ranges) > 0 + + +def test_bar_widget_respects_min_value() -> None: + # Regression: B9 - the fill width was computed from the raw value, so + # a bar at 0% progress with min_value > 0 rendered partially full. + bar = progressbar.ProgressBar( + min_value=50, + max_value=100, + widgets=[progressbar.Bar()], + fd=io.StringIO(), + term_width=60, + ) + bar.start() + assert '#' not in bar.fd.getvalue() + bar.finish(dirty=True) + + +def test_animated_marker_fill_stays_full_when_finished() -> None: + # Regression: a Bar filled by an AnimatedMarker(fill=...) collapsed to a + # single marker character at finish() because the end_time branch + # short-circuited before applying the fill. The finished bar must stay + # full instead of emptying out at 100%. + bar = progressbar.ProgressBar( + widgets=[progressbar.Bar(marker=progressbar.AnimatedMarker(fill='#'))], + max_value=10, + fd=io.StringIO(), + term_width=60, + ) + bar.start() + for i in range(11): + bar.update(i) + bar.finish() + + last_line = [ + line for line in bar.fd.getvalue().split('\n') if line.strip() + ][-1] + # term_width 60 leaves ~58 fill characters; the collapse bug left ~1 + assert last_line.count('#') > 40, repr(last_line) diff --git a/tests/test_windows.py b/tests/test_windows.py new file mode 100644 index 00000000..5823f62a --- /dev/null +++ b/tests/test_windows.py @@ -0,0 +1,117 @@ +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() + + +def test_kernel32_argtypes() -> None: + # Regression: E4 - missing argtypes silently truncated 64-bit HANDLE + # values to 32-bit C ints. + from progressbar.terminal.os_specific import windows + + assert windows._GetConsoleMode.argtypes is not None + assert windows._SetConsoleMode.argtypes is not None + assert windows._GetStdHandle.argtypes is not None + assert windows._ReadConsoleInput.argtypes is not None + + +def test_getch_reads_first_event(monkeypatch) -> None: + # Regression: E5 - getch() unconditionally decoded the second buffer + # entry, ignoring how many events were actually read. + from progressbar.terminal.os_specific import windows + + def fake_read_console_input(handle, buffer, length, events_read): + buffer[0].EventType = 1 # KEY_EVENT + buffer[0].Event.KeyEvent.bKeyDown = True + buffer[0].Event.KeyEvent.uChar.AsciiChar = b'a' + events_read._obj.value = 1 + return 1 + + monkeypatch.setattr(windows, '_ReadConsoleInput', fake_read_console_input) + assert windows.getch() == 'a' diff --git a/tests/test_with.py b/tests/test_with.py new file mode 100644 index 00000000..79307c91 --- /dev/null +++ b/tests/test_with.py @@ -0,0 +1,35 @@ +import io + +import progressbar + + +def test_with() -> None: + with progressbar.ProgressBar(max_value=10) as p: + for i in range(10): + p.update(i) + + +def test_with_stdout_redirection() -> None: + with progressbar.ProgressBar(max_value=10, redirect_stdout=True) as p: + for i in range(10): + p.update(i) + + +def test_with_extra_start() -> None: + with progressbar.ProgressBar(max_value=10) as p: + p.start() + p.start() + + +def test_context_manager_and_iterable_no_duplicate() -> None: + # Regression #301: using a bar as BOTH a context manager and an iterable + # wrapper finished it twice and drew the bar twice. + fd = io.StringIO() + with progressbar.ProgressBar( + max_value=10, fd=fd, is_terminal=True, term_width=40 + ) as bar: + for _ in bar(range(10)): + pass + # The completed bar must be rendered exactly once; the bug finished the + # bar twice (StopIteration and then __exit__), drawing it a second time. + assert fd.getvalue().count('100% (10 of 10)') == 1, repr(fd.getvalue()) 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/tests/timed.py b/tests/timed.py deleted file mode 100644 index d5c18655..00000000 --- a/tests/timed.py +++ /dev/null @@ -1,90 +0,0 @@ -import time -import progressbar - - -def test_timer(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' - widgets = [ - progressbar.Timer(), - ] - p = progressbar.ProgressBar(max_value=2, widgets=widgets, - poll_interval=0.0001) - - p.start() - p.update() - p.update(1) - p._needs_update() - time.sleep(0.001) - p.update(1) - p.finish() - - -def test_eta(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' - widgets = [ - progressbar.ETA(), - ] - p = progressbar.ProgressBar(min_value=0, max_value=2, widgets=widgets, - poll_interval=0.0001) - - p.start() - time.sleep(0.001) - p.update(0) - time.sleep(0.001) - p.update(1) - time.sleep(0.001) - p.update(1) - time.sleep(0.001) - p.update(2) - time.sleep(0.001) - p.finish() - time.sleep(0.001) - p.update(2) - - -def test_adaptive_eta(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' - widgets = [ - progressbar.AdaptiveETA(), - ] - p = progressbar.ProgressBar(max_value=2, widgets=widgets, - poll_interval=0.0001) - - p.start() - p.update(1) - time.sleep(0.001) - p.update(1) - p.finish() - - -def test_adaptive_transfer_speed(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' - widgets = [ - progressbar.AdaptiveTransferSpeed(), - ] - p = progressbar.ProgressBar(max_value=2, widgets=widgets, - poll_interval=0.0001) - - p.start() - p.update(1) - time.sleep(0.001) - p.update(1) - p.finish() - - -def test_non_changing_eta(): - '''Testing (Adaptive)ETA when the value doesn't actually change''' - widgets = [ - progressbar.AdaptiveETA(), - progressbar.ETA(), - progressbar.AdaptiveTransferSpeed(), - ] - p = progressbar.ProgressBar(max_value=2, widgets=widgets, - poll_interval=0.0001) - - p.start() - p.update(1) - time.sleep(0.001) - p.update(1) - p.finish() - diff --git a/tests/unicode.py b/tests/unicode.py deleted file mode 100644 index 6ec2aef9..00000000 --- a/tests/unicode.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- - -import time -import progressbar - - -def test_empty_arrows(): - # You may need python 3.x to see this correctly - widgets = ['Arrows: ', progressbar.AnimatedMarker(markers=u'←↖↑↗→↘↓↙')] - pbar = progressbar.ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) - - -def test_filled_arrows(): - # You may need python 3.x to see this correctly - widgets = ['Arrows: ', progressbar.AnimatedMarker(markers=u'◢◣◤◥')] - pbar = progressbar.ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) - - -def test_wheels(): - # You may need python 3.x to see this correctly - widgets = ['Wheels: ', progressbar.AnimatedMarker(markers=u'◐◓◑◒')] - pbar = progressbar.ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) - - diff --git a/tests/widgets.py b/tests/widgets.py deleted file mode 100644 index e4d8b1a2..00000000 --- a/tests/widgets.py +++ /dev/null @@ -1,95 +0,0 @@ -import time -import progressbar - - -def test_widgets_small_values(): - widgets = [ - 'Test: ', - progressbar.Percentage(), - ' ', - progressbar.Bar(marker=progressbar.RotatingMarker()), - ' ', - progressbar.ETA(), - ' ', - progressbar.FileTransferSpeed(), - ] - p = progressbar.ProgressBar(widgets=widgets, max_value=10).start() - for i in range(10): - time.sleep(0.001) - p.update(i + 1) - p.finish() - - -def test_widgets_large_values(): - widgets = [ - 'Test: ', - progressbar.Percentage(), - ' ', - progressbar.Bar(marker=progressbar.RotatingMarker()), - ' ', - progressbar.ETA(), - ' ', - 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.update(i + 1) - p.finish() - - -def test_format_widget(): - widgets = [] - for mapping in progressbar.FormatLabel.mapping: - widgets.append(progressbar.FormatLabel('%%(%s)r' % mapping)) - - p = progressbar.ProgressBar(widgets=widgets) - for i in p(range(10)): - time.sleep(0.001) - - -def test_all_widgets_small_values(): - widgets = [ - progressbar.Timer(), - progressbar.ETA(), - progressbar.AdaptiveETA(), - progressbar.FileTransferSpeed(), - progressbar.AdaptiveTransferSpeed(), - progressbar.AnimatedMarker(), - progressbar.Counter(), - progressbar.Percentage(), - progressbar.FormatLabel('%(value)d/%(max_value)d'), - progressbar.SimpleProgress(), - progressbar.Bar(), - progressbar.ReverseBar(), - progressbar.BouncingBar(), - ] - p = progressbar.ProgressBar(widgets=widgets, max_value=10) - for i in range(10): - time.sleep(0.001) - p.update(i + 1) - p.finish() - - -def test_all_widgets_large_values(): - widgets = [ - progressbar.Timer(), - progressbar.ETA(), - progressbar.AdaptiveETA(), - progressbar.FileTransferSpeed(), - progressbar.AdaptiveTransferSpeed(), - progressbar.AnimatedMarker(), - progressbar.Counter(), - progressbar.Percentage(), - progressbar.FormatLabel('%(value)d/%(max_value)d'), - progressbar.SimpleProgress(), - progressbar.Bar(), - progressbar.ReverseBar(), - progressbar.BouncingBar(), - ] - 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() - diff --git a/tests/with.py b/tests/with.py deleted file mode 100644 index 1fd2a1f6..00000000 --- a/tests/with.py +++ /dev/null @@ -1,20 +0,0 @@ -import progressbar - - -def test_with(): - with progressbar.ProgressBar(max_value=10) as p: - for i in range(10): - p.update(i) - - -def test_with_stdout_redirection(): - with progressbar.ProgressBar(max_value=10, redirect_stdout=True) as p: - for i in range(10): - p.update(i) - - -def test_with_extra_start(): - with progressbar.ProgressBar(max_value=10) as p: - p.start() - p.start() - diff --git a/tox.ini b/tox.ini index 1b5d3bdf..3f485a1d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,66 @@ [tox] -envlist = py26, py27, pypy, flake8 +envlist = + py310 + py311 + py312 + py313 + py314 + py315 + docs + black + ruff +; mypy +; codespell skip_missing_interpreters = True [testenv] -deps = -rrequirements_test.txt +deps = + -r{toxinidir}/tests/requirements.txt + pyright +commands = + pyright + py.test --basetemp="{envtmpdir}" --confcutdir=.. {posargs} +skip_install = true -commands = py.test +[testenv:mypy] +changedir = +basepython = python3 +deps = mypy +commands = mypy {toxinidir}/progressbar -[testenv:flake8] -deps = flake8 -commands = flake8 --ignore=W391 progressbar tests +[testenv:black] +basepython = python3 +deps = black +commands = black --skip-string-normalization --line-length 79 {toxinidir}/progressbar [testenv:docs] -basepython=python -changedir=docs -commands= - sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html +changedir = +basepython = python3 +deps = -r{toxinidir}/docs/requirements.txt +allowlist_externals = + rm + mkdir +whitelist_externals = + rm + cd + mkdir +commands = + rm -f docs/modules.rst + mkdir -p docs/_static + sphinx-apidoc -e -o docs/ progressbar */os_specific/* + rm -f docs/modules.rst + sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html {posargs} + +[testenv:ruff] +commands = + 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..66a184f3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1186 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[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, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/f5/3557bf28e0f1943e4849154c821533706e6dea010f96fb6aa0b6949037d1/filelock-3.29.3.tar.gz", hash = "sha256:7fc1b3f39cf172fd8203812043c57b8a65aef9969f38b6704f628b881f761a84", size = 61956, upload-time = "2026-06-10T17:37:11.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/8f/b61d427c4f49a8bdadc93f4e7e74df8a6df6f77ee6e26bf0df53d3925363/filelock-3.29.3-py3-none-any.whl", hash = "sha256:e58333029cc9b925f39aad59b1d8f0a1ad836af4e60d7217f4a4dba87461261d", size = 42324, upload-time = "2026-06-10T17:37:10.37Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[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, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "progressbar2" +source = { editable = "." } +dependencies = [ + { name = "python-utils" }, +] + +[package.optional-dependencies] +docs = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx-autodoc-typehints", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +tests = [ + { name = "dill" }, + { name = "flake8" }, + { name = "freezegun" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mypy" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[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" }, +] +provides-extras = ["docs", "tests"] + +[package.metadata.requires-dev] +dev = [{ name = "progressbar2", extras = ["docs", "tests"] }] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.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 = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mypy" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "mypy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/50/3ce149b469e27848c1dc354553b17774f9dde0140625f5a4130bd21e1052/pytest_mypy-1.0.1.tar.gz", hash = "sha256:3f5fcaff75c80dccc6b68cf5ecc28e1bbe71e95309469eb7a28bf408ce55c074", size = 15975, upload-time = "2025-04-02T19:31:16.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/93/25ed3c02e15c4ef1b04cbda7c708ffc5da755986aaacfb48db1f9e84a996/pytest_mypy-1.0.1-py3-none-any.whl", hash = "sha256:ad7133c9b92c802e032f2596590ebede7eea7c418e61d60d5cdd571b55c72056", size = 8701, upload-time = "2025-04-02T19:31:14.914Z" }, +] + +[[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, upload-time = "2024-03-01T18:36:20.211Z" } +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, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-utils" +version = "3.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/4c/ef8b7b1046d65c1f18ca31e5235c7d6627ca2b3f389ab1d44a74d22f5cc9/python_utils-3.9.1.tar.gz", hash = "sha256:eb574b4292415eb230f094cbf50ab5ef36e3579b8f09e9f2ba74af70891449a0", size = 35403, upload-time = "2024-11-26T00:38:58.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/69/31c82567719b34d8f6b41077732589104883771d182a9f4ff3e71430999a/python_utils-3.9.1-py2.py3-none-any.whl", hash = "sha256:0273d7363c7ad4b70999b2791d5ba6b55333d6f7a4e4c8b6b39fb82b5fab4613", size = 32078, upload-time = "2024-11-26T00:38:57.488Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/f0/43c6a5ff3e7b08a8c3b32f81b859f1b518ccc31e45f22e2b41ced38be7b9/sphinx_autodoc_typehints-3.0.1.tar.gz", hash = "sha256:b9b40dd15dee54f6f810c924f863f9cf1c54f9f3265c495140ea01be7f44fa55", size = 36282, upload-time = "2025-01-16T18:25:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dc/dc46c5c7c566b7ec5e8f860f9c89533bf03c0e6aadc96fb9b337867e4460/sphinx_autodoc_typehints-3.0.1-py3-none-any.whl", hash = "sha256:4b64b676a14b5b79cefb6628a6dc8070e320d4963e8ff640a2f3e9390ae9045a", size = 20245, upload-time = "2025-01-16T18:25:27.394Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/f6/bdd93582b2aaad2cfe9eb5695a44883c8bc44572dd3c351a947acbb13789/sphinx_autodoc_typehints-3.6.1.tar.gz", hash = "sha256:fa0b686ae1b85965116c88260e5e4b82faec3687c2e94d6a10f9b36c3743e2fe", size = 37563, upload-time = "2026-01-02T15:23:46.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/6a/c0360b115c81d449b3b73bf74b64ca773464d5c7b1b77bda87c5e874853b/sphinx_autodoc_typehints-3.6.1-py3-none-any.whl", hash = "sha256:dd818ba31d4c97f219a8c0fcacef280424f84a3589cedcb73003ad99c7da41ca", size = 20869, upload-time = "2026-01-02T15:23:45.194Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", +] +dependencies = [ + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/ac/99f66f906b15718687525fdf3601ca0b50d19c5e88d57cd4275a89355926/sphinx_autodoc_typehints-3.11.0.tar.gz", hash = "sha256:0112b322e2ebd993c0561af3c9e4615481b42dec199d665d6bacc875f3371e96", size = 82518, upload-time = "2026-06-11T18:48:34.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/55/7aaa2439e77cff66a6f348bb2d9894abf2b7b153595a5b974c5c277e9145/sphinx_autodoc_typehints-3.11.0-py3-none-any.whl", hash = "sha256:4ab73fe735c33168be3f34818034581155416e8e248d32ea1b604e90bea75223", size = 41610, upload-time = "2026-06-11T18:48:33.056Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[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, upload-time = "2019-01-21T16:10:16.347Z" } +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, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]