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/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..856248df --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,14 @@ +#### 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/.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..b634a35f --- /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 ] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + if: ${{ matrix.language == 'python' }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..baa93cfe --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,108 @@ +name: tox + +on: + push: + branches: [develop] + pull_request: + workflow_dispatch: + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +permissions: + contents: read + +jobs: + perf-budget: + name: Performance budget + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.13 + uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r tests/requirements.txt + python -m pip install -e . + - name: Performance budget + run: python -m pytest tests/test_perf_budget.py -v --no-cov + + 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: ruff + - python-version: '3.14' + tox-env: codespell + + 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 }} + + windows: + name: tox (windows / py312) + runs-on: windows-latest + timeout-minutes: 10 + env: + # Force UTF-8 I/O so unicode-marker tests (e.g. test_unicode) don't + # hit UnicodeEncodeError against the legacy Windows code page under + # pytest's fd-level capture. + PYTHONUTF8: '1' + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install dependencies + run: | + python -m pip install --upgrade pip tox + - name: Test with tox + # The 100% coverage gate cannot be met on Windows: POSIX-only + # branches never execute there, so run without the coverage gate + # (`--no-cov` posarg). Linux jobs still enforce full coverage. + run: tox -e py312 -- --no-cov diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..eb76940c --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,19 @@ +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 + permissions: + issues: write + pull-requests: write + 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/.npmrc b/.npmrc new file mode 100644 index 00000000..3076f8a8 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# Supply-chain safety: refuse dependency versions published less than 14 days ago. +# Honored by npm >= 11.10; silently ignored by older npm. +min-release-age=14 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..1d38c796 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +# 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: v6.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-added-large-files + +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.20 + hooks: + - id: ruff-check + - id: ruff-format 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 9d7e0476..00000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -sudo: false -language: python -python: - - "2.6" - - "2.7" - - "3.3" - - "3.4" - - "3.5" - - "pypy" - - "pypy3" - -# command to install dependencies -install: - - pip install . - - pip install -r tests/requirements.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..36b8633b --- /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. + diff --git a/CHANGES.rst b/CHANGES.rst index 95b9fede..ebb9d853 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,128 +2,10 @@ Changelog ========= -Here you can find the recent changes to Python Progressbar.. +For the most recent changes to the Python Progressbar please look at the Git +releases or the commit log: -.. changelog:: - :version: dev - :released: Ongoing + - https://github.com/WoLpH/python-progressbar/releases + - https://github.com/WoLpH/python-progressbar/commits/develop - .. change:: - :tags: docs - - Updated CHANGES. - -.. changelog:: - :version: 3.5 - :released: 2015-11-15 - - .. change:: - Added support for size-dependent widgets - - .. change:: - Fixed inheritance issues - -.. changelog:: - :version: 3.4 - :released: 2015-11-11 - - .. change:: - Added usage documentation - - .. change:: - Fixed several bugs including default widths and output redirection - - :version: 3.3 - :released: 2015-10-12 - - .. change:: - :tags: unknown-length - - Fixed `UnknownLength` handling. Thanks to @takluyver - -.. changelog:: - :version: 3.2 - :released: 2015-10-11 - - .. change:: - :tags: packaging - - Cookiecutter package - -.. changelog:: - :version: 3.1 - :released: 2015-07-11 - - .. change:: - :tags: python 3 - - Python 3 support - -2011-05-15: - - Removed parse errors for Python2.4 (no, people *should not* be using it - but it is only 3 years old and it does not have that many differences) - - - split up progressbar.py into logical units while maintaining backwards - compatability - - - Removed MANIFEST.in because it is no longer needed and it was causing - distribute to show warnings - - -2011-05-14: - - Changes to directory structure so pip can install from Google Code - - Python 3.x related fixes (all examples work on Python 3.1.3) - - Added counters, timers, and action bars for iterators with unknown length - -2010-08-29: - - Refactored some code and made it possible to use a ProgressBar as - an iterator (actually as an iterator that is a proxy to another iterator). - This simplifies showing a progress bar in a number of cases. - -2010-08-15: - - Did some minor changes to make it compatible with python 3. - -2009-05-31: - - Included check for calling start before update. - -2009-03-21: - - Improved FileTransferSpeed widget, which now supports an unit parameter, - defaulting to 'B' for bytes. It will also show B/s, MB/s, etc instead of - B/s, M/s, etc. - -2009-02-24: - - Updated licensing. - - Moved examples to separated file. - - Improved _need_update() method, which is now as fast as it can be. IOW, - no wasted cycles when an update is not needed. - -2008-12-22: - - Added SimpleProgress widget contributed by Sando Tosi - . - -2006-05-07: - - Fixed bug with terminal width in Windows. - - Released version 2.2. - -2005-12-04: - - Autodetection of terminal width. - - Added start method. - - Released version 2.1. - -2005-12-04: - - Everything is a widget now! - - Released version 2.0. - -2005-12-03: - - Rewrite using widgets. - - Released version 1.0. - -2005-06-02: - - Rewrite. - - Released version 0.5. - -2004-06-15: - - First version. - - Released version 0.1. - -.. todo:: vim: set filetype=rst: +Hint: click on the `...` button to see the change message. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 63f9db49..6e24af25 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -3,7 +3,7 @@ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every -little bit helps, and credit will always be given. +little bit helps, and credit will always be given. You can contribute in many ways: @@ -36,7 +36,7 @@ is open to whoever wants to implement it. Write Documentation ~~~~~~~~~~~~~~~~~~~ -Python Progressbar could always use more documentation, whether as part of the +Python Progressbar could always use more documentation, whether as part of the official Python Progressbar docs, in docstrings, or even on the web in blog posts, articles, and such. @@ -62,11 +62,10 @@ Ready to contribute? Here's how to set up `python-progressbar` for local develop $ git clone --branch develop git@github.com:your_name_here/python-progressbar.git -3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: +3. Install your local copy into a virtualenv. Assuming you have `uv` installed, this is how you set up your fork for local development:: - $ mkvirtualenv progressbar $ cd progressbar/ - $ pip install -e . + $ uv sync 4. Create a branch for local development with `git-flow-avh`_:: @@ -75,7 +74,7 @@ Ready to contribute? Here's how to set up `python-progressbar` for local develop Or without git-flow: $ git checkout -b feature/name-of-your-bugfix-or-feature - + Now you can make your changes locally. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: @@ -85,7 +84,7 @@ Ready to contribute? Here's how to set up `python-progressbar` for local develop $ tox To get flake8 and tox, just pip install them into your virtualenv using the requirements file. - + $ pip install -r tests/requirements.txt 6. Commit your changes and push your branch to GitHub with `git-flow-avh`_:: @@ -111,7 +110,7 @@ Before you submit a pull request, check that it meets these guidelines: 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. -3. The pull request should work for Python 2.7, 3.3, and for PyPy. Check +3. The pull request should work for Python 2.7, 3.3, and for PyPy. Check https://travis-ci.org/WoLpH/python-progressbar/pull_requests and make sure that the tests pass for all supported Python versions. @@ -123,4 +122,3 @@ To run a subset of tests:: $ py.test tests/some_test.py .. _git-flow-avh: https://github.com/petervanderdoes/gitflow - diff --git a/LICENSE b/LICENSE index f3aaf432..06ba3899 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015, Rick van Hattem (Wolph) +Copyright (c) 2022, Rick van Hattem (Wolph) All rights reserved. Redistribution and use in source and binary forms, with or without @@ -25,4 +25,3 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 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 0b5253f6..e44ef18a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,8 @@ -include AUTHORS.rst +recursive-exclude *.pyc +recursive-exclude *.pyo include CHANGES.rst include CONTRIBUTING.rst include LICENSE include README.rst -include README.txt include examples.py -include requirements.txt +include pytest.ini diff --git a/Makefile b/Makefile deleted file mode 100644 index 72116117..00000000 --- a/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -.PHONY: clean-pyc clean-build docs - -help: - @echo "clean-build - remove build artifacts" - @echo "clean-pyc - remove Python file artifacts" - @echo "lint - check style with flake8" - @echo "test - run tests quickly with the default Python" - @echo "testall - run tests on every Python version with tox" - @echo "coverage - check code coverage quickly with the default Python" - @echo "docs - generate Sphinx HTML documentation, including API docs" - @echo "release - package and upload a release" - @echo "sdist - package" - -clean: clean-build clean-pyc - -clean-build: - rm -fr build/ - rm -fr dist/ - rm -fr *.egg-info - -clean-pyc: - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '*~' -exec rm -f {} + - -lint: - flake8 progressbar tests - -test: - py.test - -test-all: - tox - -coverage: - coverage run --source progressbar setup.py test - coverage report -m - coverage html - open htmlcov/index.html - -docs: - rm -f docs/progressbar.rst - rm -f docs/modules.rst - sphinx-apidoc -o docs/ progressbar - $(MAKE) -C docs clean - $(MAKE) -C docs html - open docs/_build/html/index.html - -release: clean - python setup.py register || true - python setup.py sdist upload build_sphinx upload_sphinx - -sdist: clean - python setup.py sdist - ls -l dist diff --git a/README.rst b/README.rst index 40bf82a4..8301b6a1 100644 --- a/README.rst +++ b/README.rst @@ -1,63 +1,175 @@ -Text progress bar library for Python. -===================================== +############################################################################## +progressbar2 +############################################################################## -Travis status: +A mature, typed terminal progress bar library for Python scripts that need +custom widgets, clean output around prints and logs, multiple concurrent bars, +unknown-length progress, and pipe-friendly CLI usage. -.. image:: https://travis-ci.org/WoLpH/python-progressbar.png?branch=master - :target: https://travis-ci.org/WoLpH/python-progressbar +.. image:: https://github.com/WoLpH/python-progressbar/actions/workflows/main.yml/badge.svg + :alt: python-progressbar test status + :target: https://github.com/WoLpH/python-progressbar/actions -Coverage: +.. image:: https://coveralls.io/repos/WoLpH/python-progressbar/badge.svg?branch=master + :alt: coverage status + :target: https://coveralls.io/r/WoLpH/python-progressbar?branch=master -.. image:: https://coveralls.io/repos/WoLpH/python-progressbar/badge.png?branch=master - :target: https://coveralls.io/r/WoLpH/python-progressbar?branch=master +Install +============================================================================== -Introduction ------------- +.. code:: sh -.. 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 +Quick start +============================================================================== -A text progress bar is typically used to display the progress of a long -running operation, providing a visual cue that processing is underway. +.. code:: python -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: + import time + import progressbar - - `Timer` - - `ETA` - - `AdaptiveETA` - - `FileTransferSpeed` - - `AdaptiveTransferSpeed` - - `AnimatedMarker` - - `Counter` - - `Percentage` - - `FormatLabel` - - `SimpleProgress` - - `Bar` - - `ReverseBar` - - `BouncingBar` - - `RotatingMarker` + for item in progressbar.progressbar(range(100), desc='Loading'): + time.sleep(0.02) -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. +Progress with clean logs +============================================================================== + +.. image:: docs/_static/progressbar-hero.svg + :alt: progressbar2 showing clean progress output with logs + +.. code:: python + + import sys + import time + import progressbar + + with progressbar.ProgressBar( + total=24, + desc='Build', + fd=sys.stdout, + redirect_stdout=True, + line_breaks=False, + is_terminal=True, + enable_colors=True, + term_width=112, + ) as bar: + for step in range(24): + if step in {8, 16}: + print(f'log: completed step {step}') + bar.update(step + 1, force=True) + time.sleep(0.005) + +Multiple bars +============================================================================== + +.. image:: docs/_static/progressbar-multibar.svg + :alt: multiple progress bars updating together + +.. code:: python + + import io + import re + import progressbar + + fd = io.StringIO() + multibar = progressbar.MultiBar( + fd=fd, + total=24, + enable_colors=True, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + term_width=112, + ) + build = multibar['build'] + test = multibar['test'] + terminal_control_re = re.compile(r'\x1b\[[0-9;]*[A-Za-ln-z]') + + def emit_frame(): + output = terminal_control_re.sub('', fd.getvalue()) + for line in output.split('\r'): + line = line.strip() + if line: + print(line) + print('\f', end='') + fd.seek(0) + fd.truncate(0) + + multibar.render(force=True, flush=True) + emit_frame() + + for step in range(24): + build.update(step + 1, force=True) + test_value = min(24, max(0, round((step - 3) * 1.2))) + test.update(test_value, force=True) + multibar.render(force=True, flush=True) + emit_frame() + +Unknown length and animated bars +============================================================================== + +.. image:: docs/_static/progressbar-unknown-length.svg + :alt: unknown length progress with an animated marker + +.. code:: python + + import sys + import progressbar + + with progressbar.ProgressBar( + max_value=progressbar.UnknownLength, + fd=sys.stdout, + line_breaks=False, + is_terminal=True, + enable_colors=True, + term_width=112, + ) as bar: + for value in range(0, 120, 10): + bar.update(value, force=True) + +CLI usage +============================================================================== + +.. code:: sh + + progressbar --progress --timer --eta --rate --bytes input.bin -o output.bin + +Feature highlights +============================================================================== + +* Works as an iterable wrapper or a manually updated progress bar. +* Supports custom widgets, colors, granular bars, animated markers, and labels. +* Handles unknown-length iterators. +* Supports multiple concurrent progress bars with ``MultiBar``. +* Redirects stdout/stderr so regular output does not corrupt the active bar. +* Includes a pipe-friendly ``progressbar`` command. +* Ships typed package metadata. + +Known terminal caveats +============================================================================== + +* JetBrains IDEs need "Enable terminal in output console" for advanced + terminal behavior such as ``MultiBar``. +* IDLE does not support terminal progress bars. +* Jupyter buffers stdout; call ``sys.stdout.flush()`` when output appears late. + +Project history +============================================================================== + +progressbar2 is based on the old Python progressbar package that was published +on the now defunct Google Code. Since that project was completely abandoned by +its developer and the developer did not respond to email, I decided to fork the +package. + +This package is still backwards compatible with the original progressbar package +so you can safely use it as a drop-in replacement for existing projects. Links ------ - -* Documentation - - http://progressbar-2.readthedocs.org/en/latest/ -* Source - - https://github.com/WoLpH/python-progressbar -* Bug reports - - https://github.com/WoLpH/python-progressbar/issues -* Package homepage - - https://pypi.python.org/pypi/progressbar2 -* My blog - - http://w.wol.ph/ +============================================================================== +* Documentation: https://progressbar-2.readthedocs.org/en/latest/ +* Source: https://github.com/WoLpH/python-progressbar +* Bug reports: https://github.com/WoLpH/python-progressbar/issues +* Package homepage: https://pypi.python.org/pypi/progressbar2 diff --git a/benchmarks/bench.py b/benchmarks/bench.py new file mode 100644 index 00000000..74cd3eb9 --- /dev/null +++ b/benchmarks/bench.py @@ -0,0 +1,348 @@ +"""Benchmark progressbar2 against other common Python progress-bar libraries. + +Measures three things, fairly, with all rendered output sent to a *real* pseudo +terminal (so every library believes it is attached to a TTY and actually draws): + + A. Default iterator-wrap overhead .. the idiomatic "wrap my loop" call with + each library's default settings (ns added per iteration). Headline number. + B. Forced per-update render cost ... rendering forced on every single update, + for the libraries whose API supports it (us per rendered update). + C. Import time ...................... cold `import` cost in a fresh interpreter + (ms), interpreter-startup baseline subtracted. + +Results are written to results.json for the reporting step to consume. +""" + +from __future__ import annotations + +import fcntl +import gc +import json +import os +import platform +import pty +import statistics +import struct +import subprocess +import sys +import termios +import threading +import time +import typing +from importlib import metadata + +# Scenario sizes / repeats ------------------------------------------------- +N_ITER: int = 1_000_000 # scenario A: default-overhead loop length +ITER_REPEATS: int = 7 +N_RENDER: int = 30_000 # scenario B: forced-render loop length +RENDER_REPEATS: int = 5 +IMPORT_RUNS: int = 9 # scenario C: cold-import subprocess runs + +TERM_COLS: int = 80 +TERM_ROWS: int = 24 + + +class PtySink: + """A real pty whose output is continuously drained and discarded. + + Writing to a pty that nobody reads will eventually block when the kernel + buffer fills; the background drain thread keeps it flowing so timings are + not polluted by blocked writes. + """ + + def __init__(self, cols: int = TERM_COLS, rows: int = TERM_ROWS) -> None: + self._master, slave = pty.openpty() + fcntl.ioctl( + slave, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0) + ) + self.file: typing.TextIO = os.fdopen( + slave, 'w', buffering=1, encoding='utf-8', errors='replace' + ) + self._stop = threading.Event() + self._thread = threading.Thread(target=self._drain, daemon=True) + self._thread.start() + + def _drain(self) -> None: + while not self._stop.is_set(): + try: + if not os.read(self._master, 65536): + break + except OSError: + break + + def close(self) -> None: + try: + self.file.flush() + self.file.close() + except Exception: + # Teardown only: the slave fd may already be gone; nothing to do. + pass + self._stop.set() + try: + os.close(self._master) + except OSError: + # Master already closed once the drain thread hit EOF; ignore. + pass + self._thread.join(timeout=1) + + +def time_call(fn: typing.Callable[[], None], repeats: int) -> dict[str, float]: + """Run ``fn`` ``repeats`` times (plus one warmup); return min/median secs.""" + fn() # warmup: pay one-time import/compile/thread-spawn costs + samples: list[float] = [] + for _ in range(repeats): + gc.collect() + gc.disable() + start = time.perf_counter() + fn() + elapsed = time.perf_counter() - start + gc.enable() + samples.append(elapsed) + return {'min': min(samples), 'median': statistics.median(samples)} + + +# --- Scenario A: default iterator-wrap overhead --------------------------- + + +def baseline_loop(n: int) -> None: + for _ in range(n): + pass + + +def iter_progressbar2(f: typing.TextIO, n: int) -> None: + import progressbar + + for _ in progressbar.progressbar(range(n), fd=f): + pass + + +def iter_tqdm(f: typing.TextIO, n: int) -> None: + from tqdm import tqdm + + for _ in tqdm(range(n), file=f): + pass + + +def iter_rich(f: typing.TextIO, n: int) -> None: + from rich.console import Console + from rich.progress import track + + console = Console(file=f, force_terminal=True, width=TERM_COLS) + for _ in track(range(n), console=console): + pass + + +def iter_alive(f: typing.TextIO, n: int) -> None: + from alive_progress import alive_bar + + with alive_bar(n, file=f, force_tty=True) as bar: + for _ in range(n): + bar() + + +def iter_click(f: typing.TextIO, n: int) -> None: + import click + + with click.progressbar(range(n), file=f) as bar: + for _ in bar: + pass + + +# --- Scenario B: forced per-update render --------------------------------- + + +def render_progressbar2(f: typing.TextIO, n: int) -> None: + import progressbar + + # force=True bypasses the time-based redraw throttle (whose floor is + # _MINIMUM_UPDATE_INTERVAL=0.050s), so every update actually renders. + with progressbar.ProgressBar(max_value=n, fd=f) as bar: + for i in range(n): + bar.update(i + 1, force=True) + + +def render_progressbar2_fast(f: typing.TextIO, n: int) -> None: + import progressbar + + # The fast default path: fixed formatter, no widget machinery. + with progressbar.FastProgressBar(max_value=n, fd=f) as bar: + for i in range(n): + bar.update(i + 1, force=True) + + +def render_tqdm(f: typing.TextIO, n: int) -> None: + from tqdm import tqdm + + for _ in tqdm(range(n), file=f, mininterval=0, miniters=1): + pass + + +def render_rich(f: typing.TextIO, n: int) -> None: + from rich.console import Console + from rich.progress import Progress + + console = Console(file=f, force_terminal=True, width=TERM_COLS) + with Progress(console=console, auto_refresh=False) as progress: + task = progress.add_task('bench', total=n) + for _ in range(n): + progress.advance(task) + progress.refresh() + + +# --- Scenario C: cold import time ----------------------------------------- + +IMPORT_STMTS: dict[str, str] = { + 'progressbar2': 'import progressbar', + 'tqdm': 'from tqdm import tqdm', + 'rich': 'from rich.progress import track', + 'alive-progress': 'from alive_progress import alive_bar', + 'click': 'import click', +} + + +def time_import(stmt: str, runs: int) -> float: + """Return the minimum wall-clock seconds to run ``stmt`` in a fresh py.""" + samples: list[float] = [] + for _ in range(runs): + start = time.perf_counter() + subprocess.run( + [sys.executable, '-c', stmt], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + samples.append(time.perf_counter() - start) + return min(samples) + + +ITER_LIBS: dict[str, typing.Callable[[typing.TextIO, int], None]] = { + 'progressbar2': iter_progressbar2, + 'tqdm': iter_tqdm, + 'rich': iter_rich, + 'alive-progress': iter_alive, + 'click': iter_click, +} + +RENDER_LIBS: dict[str, typing.Callable[[typing.TextIO, int], None]] = { + 'progressbar2': render_progressbar2, + 'progressbar2-fast': render_progressbar2_fast, + 'tqdm': render_tqdm, + 'rich': render_rich, +} + + +def main() -> None: + sink = PtySink() + results: dict[str, typing.Any] = { + 'meta': { + 'python': platform.python_version(), + 'implementation': platform.python_implementation(), + 'platform': platform.platform(), + 'processor': platform.processor() or platform.machine(), + 'cpu_count': os.cpu_count(), + 'versions': { + name: metadata.version(dist) + for name, dist in { + 'progressbar2': 'progressbar2', + 'tqdm': 'tqdm', + 'rich': 'rich', + 'alive-progress': 'alive-progress', + 'click': 'click', + }.items() + }, + 'n_iter': N_ITER, + 'iter_repeats': ITER_REPEATS, + 'n_render': N_RENDER, + 'render_repeats': RENDER_REPEATS, + 'import_runs': IMPORT_RUNS, + 'term': f'{TERM_COLS}x{TERM_ROWS}', + }, + } + + try: + # Scenario A ---------------------------------------------------- + print('[A] default iterator-wrap overhead', file=sys.stderr) + base = time_call(lambda: baseline_loop(N_ITER), ITER_REPEATS) + print( + f' baseline {base["min"] * 1e3:8.2f} ms', + file=sys.stderr, + ) + iter_results: dict[str, typing.Any] = {} + for name, fn in ITER_LIBS.items(): + res = time_call(lambda f=fn: f(sink.file, N_ITER), ITER_REPEATS) + overhead_ns = (res['min'] - base['min']) / N_ITER * 1e9 + iter_results[name] = { + 'total_min_s': res['min'], + 'total_median_s': res['median'], + 'overhead_ns_per_iter': overhead_ns, + } + print( + f' {name:16} {res["min"] * 1e3:8.2f} ms ' + f'({overhead_ns:8.1f} ns/iter)', + file=sys.stderr, + ) + results['scenario_a_default_overhead'] = { + 'baseline_min_s': base['min'], + 'baseline_median_s': base['median'], + 'libs': iter_results, + } + + # Scenario B ---------------------------------------------------- + print('[B] forced per-update render', file=sys.stderr) + baseR = time_call(lambda: baseline_loop(N_RENDER), RENDER_REPEATS) + render_results: dict[str, typing.Any] = {} + for name, fn in RENDER_LIBS.items(): + res = time_call( + lambda f=fn: f(sink.file, N_RENDER), RENDER_REPEATS + ) + per_update_us = (res['min'] - baseR['min']) / N_RENDER * 1e6 + render_results[name] = { + 'total_min_s': res['min'], + 'total_median_s': res['median'], + 'per_update_us': per_update_us, + } + print( + f' {name:16} {res["min"] * 1e3:8.2f} ms ' + f'({per_update_us:7.2f} us/update)', + file=sys.stderr, + ) + results['scenario_b_forced_render'] = { + 'baseline_min_s': baseR['min'], + 'libs': render_results, + 'excluded': { + 'alive-progress': 'renders on a background timer thread; no ' + 'per-update render API', + 'click': 'self-throttles writes (renders only when the drawn ' + 'line changes); no force-every-update API', + }, + } + finally: + sink.close() + + # Scenario C -------------------------------------------------------- + print('[C] cold import time', file=sys.stderr) + base_import = time_import('pass', IMPORT_RUNS) + import_results: dict[str, typing.Any] = {} + for name, stmt in IMPORT_STMTS.items(): + t = time_import(stmt, IMPORT_RUNS) + net_ms = (t - base_import) * 1e3 + import_results[name] = { + 'total_min_s': t, + 'net_ms': net_ms, + } + print(f' {name:16} {net_ms:8.1f} ms (net)', file=sys.stderr) + results['scenario_c_import_time'] = { + 'interpreter_baseline_s': base_import, + 'libs': import_results, + } + + out = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'results.json' + ) + with open(out, 'w', encoding='utf-8') as fh: + json.dump(results, fh, indent=2) + print(f'\nwrote {out}', file=sys.stderr) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/chart.png b/benchmarks/chart.png new file mode 100644 index 00000000..47d69295 Binary files /dev/null and b/benchmarks/chart.png differ diff --git a/benchmarks/report.md b/benchmarks/report.md new file mode 100644 index 00000000..368e56f6 --- /dev/null +++ b/benchmarks/report.md @@ -0,0 +1,83 @@ +# Python progress-bar library benchmark + +_Generated 2026-06-27 03:03. Subject: **progressbar2** (version 4.5.0)._ + +Compares `progressbar2` against the most common alternatives across three independent dimensions. All rendered output is written to a real pseudo-terminal (pty) that is continuously drained, so every library believes it is attached to a TTY and actually draws — the comparison is apples-to-apples, not "is output suppressed when piped". + +![benchmark chart](chart.png) + +## Environment + +| | | +|---|---| +| Python | CPython 3.13.12 | +| Platform | macOS-26.5-arm64-arm-64bit-Mach-O | +| Processor | arm (18 cores) | +| Terminal | 80x24 (pty) | + +| Library | Version | +|---|---| +| progressbar2 | 4.5.0 | +| tqdm | 4.68.3 | +| rich | 15.0.0 | +| alive-progress | 3.3.0 | +| click | 8.4.1 | + +## A. Default iterator-wrap overhead (headline) + +Idiomatic "wrap my loop" call with each library's **default** settings, over **1,000,000** iterations with a trivial body. This is the real-world cost of dropping a progress bar around a fast loop. Overhead = (wrapped time − bare-loop time) / iterations. Lower is faster. + +Bare loop baseline: **5.47 ms** for 1,000,000 iterations. + +| Library | Total time | Overhead/iter | vs progressbar2 | +|---|--:|--:|--:| +| **progressbar2** | 10.5 ms | 5.1 ns | baseline | +| rich | 24.5 ms | 19.0 ns | 3.76x | +| tqdm | 59.3 ms | 53.8 ns | 10.64x | +| alive-progress | 253.5 ms | 248.0 ns | 49.06x | +| click | 1861.4 ms | 1855.9 ns | 367.13x | + +## B. Forced per-update render cost + +Rendering **forced on every single update** over **30,000** updates — i.e. the cost of one full bar redraw, throttling disabled. Lower is faster. + +| Library | Total time | Per rendered update | vs progressbar2 | +|---|--:|--:|--:| +| **progressbar2-fast** | 148.9 ms | 4.96 us | 0.19x | +| tqdm | 332.7 ms | 11.08 us | 0.44x | +| **progressbar2** | 764.6 ms | 25.48 us | baseline | +| rich | 5156.6 ms | 171.88 us | 6.75x | + +Excluded from this panel (no per-update force-render API): +- **alive-progress** — renders on a background timer thread; no per-update render API +- **click** — self-throttles writes (renders only when the drawn line changes); no force-every-update API + +## C. Cold import time + +Wall-clock cost of importing the library in a fresh interpreter (minimum of 9 runs), with bare-interpreter startup (15 ms) subtracted. Matters for short-lived CLIs. Lower is lighter. + +| Library | Import time (net) | +|---|--:| +| **progressbar2** | 1.5 ms | +| alive-progress | 8.5 ms | +| tqdm | 21.6 ms | +| click | 23.5 ms | +| rich | 46.9 ms | + +## Takeaways + +- **Default per-iteration overhead:** `progressbar2` is 5 ns/iter, ranking #1 of 5. `progressbar2` is the lightest per iteration (5 ns), `click` the heaviest (1856 ns). + - `progressbar2` and `tqdm` win here because their default settings do almost no per-iteration work (counter compare / background refresh thread); `progressbar2` calls a monotonic clock and evaluates its redraw predicate on every `update()`. +- **Render cost:** when a redraw actually happens, `progressbar2` draws one update in 25.5 us — 5.14x the cheapest (`progressbar2-fast`) but 6.7x cheaper than rich's full-display re-render. +- **Why both numbers matter:** `progressbar2` caps redraws at ~20/sec by default (50 ms floor), so in practice the cheap render in B fires rarely and the per-iteration cost in A dominates real workloads. +- **Import weight:** `progressbar2` is mid-pack to import; `alive-progress` is the lightest, `rich` the heaviest. + +## Methodology & caveats + +- Timing: `time.perf_counter`, GC disabled during measurement, one untimed warmup per case, **minimum** of N repeats reported (A: 7, B: 5). Minimum is used to reduce scheduler/JIT noise. +- Output goes to a real pty sized 80x24, drained by a background thread so writes never block. +- "Overhead/iter" subtracts the bare-loop baseline, isolating the library's own cost. +- Default settings reflect out-of-the-box behaviour; tuning (`mininterval`, `poll_interval`, etc.) shifts these numbers. Results are specific to the environment above and will vary by machine. +- This measures CPU/throughput overhead only — not feature set, output quality, nesting, or multi-bar support. + +Reproduce: `python benchmarks/bench.py && python benchmarks/report.py` diff --git a/benchmarks/report.py b/benchmarks/report.py new file mode 100644 index 00000000..ce336b49 --- /dev/null +++ b/benchmarks/report.py @@ -0,0 +1,402 @@ +"""Render results.json into chart.png + report.md.""" + +from __future__ import annotations + +import datetime +import json +import os +import typing + +import matplotlib + +matplotlib.use('Agg') +import matplotlib.pyplot as plt # noqa: E402 +from matplotlib import font_manager # noqa: E402 + +HERE: str = os.path.dirname(os.path.abspath(__file__)) +SUBJECT: str = 'progressbar2' + +# Our two variants share one brand hue so they read as siblings: the fast +# default pops at full saturation, the full (widget) mode is a muted shade. +# Every other library is a flat neutral grey. +FAST: str = 'fast' +FULL: str = 'full' +OTHER: str = 'other' +THEME: dict[str, typing.Any] = { + 'bg': '#ffffff', + 'fast': '#4f46e5', # bold indigo -> progressbar2 (fast), the default + 'full': '#c4b5fd', # muted indigo -> progressbar2 (full), widgets mode + 'other': '#d8dde6', # neutral grey -> every other library + 'text': '#1e2430', + 'subtext': '#5b6472', + 'grid': '#e7eaf0', + 'title': '#11151c', + 'bar_height': 0.6, +} + + +def _font() -> str: + """Prefer a clean sans; degrade gracefully where it is not installed.""" + have = {f.name for f in font_manager.fontManager.ttflist} + for name in ('Helvetica Neue', 'Helvetica', 'Arial', 'DejaVu Sans'): + if name in have: + return name + return 'sans-serif' + + +def _classify(panel: str, key: str) -> str: + """Tag a library as our fast default, our full mode, or another lib.""" + if key == 'progressbar2-fast': + return FAST + if key == SUBJECT: + # Panels A/C only benchmark the fast default; B splits fast vs full. + return FAST if panel in ('A', 'C') else FULL + return OTHER + + +def _relabel(panel: str, key: str) -> str: + """Display name: spell out fast/full for our bars, raw key otherwise.""" + cls = _classify(panel, key) + if cls == FAST: + return 'progressbar2 (fast)' + if cls == FULL: + return 'progressbar2 (full)' + return key + + +def load() -> dict[str, typing.Any]: + with open(os.path.join(HERE, 'results.json'), encoding='utf-8') as fh: + return json.load(fh) + + +def _sorted(pairs: dict[str, float]) -> list[tuple[str, float]]: + return sorted(pairs.items(), key=lambda kv: kv[1]) + + +def make_chart(data: dict[str, typing.Any]) -> str: + a = data['scenario_a_default_overhead']['libs'] + b = data['scenario_b_forced_render']['libs'] + c = data['scenario_c_import_time']['libs'] + + panels: list[tuple[str, str, str, list[tuple[str, float]], bool]] = [ + ( + 'A', + 'Default iterator-wrap overhead', + 'nanoseconds added per iteration', + _sorted({k: v['overhead_ns_per_iter'] for k, v in a.items()}), + True, + ), + ( + 'B', + 'Forced per-update render cost', + 'microseconds per rendered update', + _sorted({k: v['per_update_us'] for k, v in b.items()}), + True, + ), + ( + 'C', + 'Cold import time', + 'milliseconds (net of startup)', + _sorted({k: v['net_ms'] for k, v in c.items()}), + False, + ), + ] + + color = {FAST: THEME['fast'], FULL: THEME['full'], OTHER: THEME['other']} + + plt.rcParams['font.family'] = _font() + fig, axes = plt.subplots(1, 3, figsize=(15.5, 5.2)) + fig.patch.set_facecolor(THEME['bg']) + + for ax, (pid, ptitle, xlabel, pairs, logx) in zip(axes, panels): + ax.set_facecolor(THEME['bg']) + classes = [_classify(pid, k) for k, _ in pairs] + labels = [_relabel(pid, k) for k, _ in pairs] + values = [v for _, v in pairs] + ypos = list(range(len(labels))) + + ax.barh( + ypos, + values, + height=THEME['bar_height'], + color=[color[cls] for cls in classes], + ) + ax.set_yticks(ypos) + ax.set_yticklabels(labels, color=THEME['text'], fontsize=10) + ax.invert_yaxis() # fastest at top + ax.set_xlabel(xlabel, color=THEME['subtext'], fontsize=9.5) + ax.set_title( + f'{pid}. {ptitle}', + loc='left', + fontsize=11.5, + fontweight='bold', + color=THEME['title'], + pad=12, + ) + if logx: + ax.set_xscale('log') + ax.grid(axis='x', color=THEME['grid'], linewidth=1) + ax.set_axisbelow(True) + for spine in ('top', 'right', 'left'): + ax.spines[spine].set_visible(False) + ax.spines['bottom'].set_color(THEME['grid']) + ax.tick_params(colors=THEME['subtext'], length=0) + ax.margins(x=0.2) + + xmax = max(values) + for y, val, cls in zip(ypos, values, classes): + label = f'{val:.1f}' if val >= 1 else f'{val:.2f}' + ax.text( + val * 1.08 if logx else val + xmax * 0.015, + y, + label, + va='center', + ha='left', + fontsize=9.5, + fontweight='normal' if cls == OTHER else 'bold', + color=THEME['text'] if cls == OTHER else color[cls], + ) + + fig.suptitle( + 'progressbar2 vs common Python progress-bar libraries', + fontsize=15, + fontweight='bold', + color=THEME['title'], + y=0.975, + ) + fig.text( + 0.5, + 0.915, + 'lower is faster / lighter — fastest at top', + ha='center', + fontsize=9.5, + color=THEME['subtext'], + ) + fig.tight_layout(rect=(0, 0, 1, 0.88)) + out = os.path.join(HERE, 'chart.png') + fig.savefig(out, dpi=140, facecolor=THEME['bg']) + plt.close(fig) + return out + + +def _rel(value: float, ref: float) -> str: + if ref == 0: + return 'n/a' + factor = value / ref + if abs(factor - 1) < 0.005: + return 'baseline' + return f'{factor:.2f}x' + + +def make_report(data: dict[str, typing.Any], chart_name: str) -> str: + meta = data['meta'] + a = data['scenario_a_default_overhead'] + b = data['scenario_b_forced_render'] + c = data['scenario_c_import_time'] + n_iter = meta['n_iter'] + n_render = meta['n_render'] + now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M') + + pb_a = a['libs'][SUBJECT]['overhead_ns_per_iter'] + pb_b = b['libs'][SUBJECT]['per_update_us'] + + lines: list[str] = [] + w = lines.append + + w('# Python progress-bar library benchmark') + w('') + w( + f'_Generated {now}. Subject: **{SUBJECT}** ' + f'(version {meta["versions"]["progressbar2"]})._' + ) + w('') + w( + 'Compares `progressbar2` against the most common alternatives across ' + 'three independent dimensions. All rendered output is written to a real ' + 'pseudo-terminal (pty) that is continuously drained, so every library ' + 'believes it is attached to a TTY and actually draws — the comparison is ' + 'apples-to-apples, not "is output suppressed when piped".' + ) + w('') + w(f'![benchmark chart]({chart_name})') + w('') + + # Environment ------------------------------------------------------ + w('## Environment') + w('') + w('| | |') + w('|---|---|') + w(f'| Python | {meta["implementation"]} {meta["python"]} |') + w(f'| Platform | {meta["platform"]} |') + w(f'| Processor | {meta["processor"]} ({meta["cpu_count"]} cores) |') + w(f'| Terminal | {meta["term"]} (pty) |') + w('') + w('| Library | Version |') + w('|---|---|') + for name, ver in meta['versions'].items(): + w(f'| {name} | {ver} |') + w('') + + # Scenario A ------------------------------------------------------- + w('## A. Default iterator-wrap overhead (headline)') + w('') + w( + f'Idiomatic "wrap my loop" call with each library\'s **default** ' + f'settings, over **{n_iter:,}** iterations with a trivial body. This is ' + f'the real-world cost of dropping a progress bar around a fast loop. ' + f'Overhead = (wrapped time − bare-loop time) / iterations. ' + f'Lower is faster.' + ) + w('') + w( + f'Bare loop baseline: **{a["baseline_min_s"] * 1e3:.2f} ms** ' + f'for {n_iter:,} iterations.' + ) + w('') + w('| Library | Total time | Overhead/iter | vs progressbar2 |') + w('|---|--:|--:|--:|') + for name, v in _sorted( + {k: vv['overhead_ns_per_iter'] for k, vv in a['libs'].items()} + ): + vv = a['libs'][name] + bold = '**' if name in (SUBJECT, 'progressbar2-fast') else '' + w( + f'| {bold}{name}{bold} | {vv["total_min_s"] * 1e3:.1f} ms ' + f'| {vv["overhead_ns_per_iter"]:.1f} ns ' + f'| {_rel(vv["overhead_ns_per_iter"], pb_a)} |' + ) + w('') + + # Scenario B ------------------------------------------------------- + w('## B. Forced per-update render cost') + w('') + w( + f'Rendering **forced on every single update** over **{n_render:,}** ' + f'updates — i.e. the cost of one full bar redraw, throttling disabled. ' + f'Lower is faster.' + ) + w('') + w('| Library | Total time | Per rendered update | vs progressbar2 |') + w('|---|--:|--:|--:|') + for name, v in _sorted( + {k: vv['per_update_us'] for k, vv in b['libs'].items()} + ): + vv = b['libs'][name] + bold = '**' if name in (SUBJECT, 'progressbar2-fast') else '' + w( + f'| {bold}{name}{bold} | {vv["total_min_s"] * 1e3:.1f} ms ' + f'| {vv["per_update_us"]:.2f} us ' + f'| {_rel(vv["per_update_us"], pb_b)} |' + ) + w('') + w('Excluded from this panel (no per-update force-render API):') + for name, why in b['excluded'].items(): + w(f'- **{name}** — {why}') + w('') + + # Scenario C ------------------------------------------------------- + w('## C. Cold import time') + w('') + w( + f'Wall-clock cost of importing the library in a fresh interpreter ' + f'(minimum of {meta["import_runs"]} runs), with bare-interpreter startup ' + f'({c["interpreter_baseline_s"] * 1e3:.0f} ms) subtracted. Matters for ' + f'short-lived CLIs. Lower is lighter.' + ) + w('') + w('| Library | Import time (net) |') + w('|---|--:|') + for name, v in _sorted({k: vv['net_ms'] for k, vv in c['libs'].items()}): + vv = c['libs'][name] + bold = '**' if name in (SUBJECT, 'progressbar2-fast') else '' + w(f'| {bold}{name}{bold} | {vv["net_ms"]:.1f} ms |') + w('') + + # Takeaways -------------------------------------------------------- + a_rank = _sorted( + {k: vv['overhead_ns_per_iter'] for k, vv in a['libs'].items()} + ) + b_rank = _sorted({k: vv['per_update_us'] for k, vv in b['libs'].items()}) + pb_a_pos = [k for k, _ in a_rank].index(SUBJECT) + 1 + fastest_a = a_rank[0][0] + slowest_a = a_rank[-1][0] + w('## Takeaways') + w('') + w( + f'- **Default per-iteration overhead:** `{SUBJECT}` is ' + f'{pb_a:.0f} ns/iter, ranking #{pb_a_pos} of ' + f'{len(a_rank)}. `{fastest_a}` is the lightest per iteration ' + f'({a_rank[0][1]:.0f} ns), `{slowest_a}` the heaviest ' + f'({a_rank[-1][1]:.0f} ns).' + ) + w( + f' - `{fastest_a}` and `tqdm` win here because their default settings ' + f'do almost no per-iteration work (counter compare / background refresh ' + f'thread); `{SUBJECT}` calls a monotonic clock and evaluates its redraw ' + f'predicate on every `update()`.' + ) + w( + f'- **Render cost:** when a redraw actually happens, `{SUBJECT}` draws ' + f'one update in {b_rank[[k for k, _ in b_rank].index(SUBJECT)][1]:.1f} us ' + f'— {_rel(pb_b, b_rank[0][1])} the cheapest (`{b_rank[0][0]}`) but ' + f"{b['libs']['rich']['per_update_us'] / pb_b:.1f}x cheaper than rich's " + f'full-display re-render.' + ) + w( + f'- **Why both numbers matter:** `{SUBJECT}` caps redraws at ~20/sec by ' + f'default (50 ms floor), so in practice the cheap render in B fires ' + f'rarely and the per-iteration cost in A dominates real workloads.' + ) + w( + f'- **Import weight:** `{SUBJECT}` is mid-pack to import; ' + f'`alive-progress` is the lightest, `rich` the heaviest.' + ) + w('') + + # Methodology ------------------------------------------------------ + w('## Methodology & caveats') + w('') + w( + f'- Timing: `time.perf_counter`, GC disabled during measurement, one ' + f'untimed warmup per case, **minimum** of N repeats reported ' + f'(A: {meta["iter_repeats"]}, B: {meta["render_repeats"]}). Minimum is ' + f'used to reduce scheduler/JIT noise.' + ) + w( + '- Output goes to a real pty sized ' + f'{meta["term"]}, drained by a background thread so writes never block.' + ) + w( + '- "Overhead/iter" subtracts the bare-loop baseline, isolating the ' + "library's own cost." + ) + w( + '- Default settings reflect out-of-the-box behaviour; tuning ' + '(`mininterval`, `poll_interval`, etc.) shifts these numbers. Results ' + 'are specific to the environment above and will vary by machine.' + ) + w( + '- This measures CPU/throughput overhead only — not feature set, output ' + 'quality, nesting, or multi-bar support.' + ) + w('') + w('Reproduce: `python benchmarks/bench.py && python benchmarks/report.py`') + w('') + + report = '\n'.join(lines) + out = os.path.join(HERE, 'report.md') + with open(out, 'w', encoding='utf-8') as fh: + fh.write(report) + return out + + +def main() -> None: + data = load() + chart = make_chart(data) + report = make_report(data, os.path.basename(chart)) + print('wrote', chart) + print('wrote', report) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/requirements.txt b/benchmarks/requirements.txt new file mode 100644 index 00000000..d5cbec28 --- /dev/null +++ b/benchmarks/requirements.txt @@ -0,0 +1,5 @@ +tqdm==4.68.3 +rich==15.0.0 +alive-progress==3.3.0 +click==8.4.1 +matplotlib==3.11.0 diff --git a/benchmarks/results.json b/benchmarks/results.json new file mode 100644 index 00000000..b8a8141f --- /dev/null +++ b/benchmarks/results.json @@ -0,0 +1,107 @@ +{ + "meta": { + "python": "3.13.12", + "implementation": "CPython", + "platform": "macOS-26.5-arm64-arm-64bit-Mach-O", + "processor": "arm", + "cpu_count": 18, + "versions": { + "progressbar2": "4.5.0", + "tqdm": "4.68.3", + "rich": "15.0.0", + "alive-progress": "3.3.0", + "click": "8.4.1" + }, + "n_iter": 1000000, + "iter_repeats": 7, + "n_render": 30000, + "render_repeats": 5, + "import_runs": 9, + "term": "80x24" + }, + "scenario_a_default_overhead": { + "baseline_min_s": 0.005465084221214056, + "baseline_median_s": 0.005537374876439571, + "libs": { + "progressbar2": { + "total_min_s": 0.01052025007084012, + "total_median_s": 0.010792375076562166, + "overhead_ns_per_iter": 5.055165849626064 + }, + "tqdm": { + "total_min_s": 0.05926787527278066, + "total_median_s": 0.06034625042229891, + "overhead_ns_per_iter": 53.8027910515666 + }, + "rich": { + "total_min_s": 0.024457333143800497, + "total_median_s": 0.024638874921947718, + "overhead_ns_per_iter": 18.99224892258644 + }, + "alive-progress": { + "total_min_s": 0.2534806248731911, + "total_median_s": 0.26495025027543306, + "overhead_ns_per_iter": 248.01554065197706 + }, + "click": { + "total_min_s": 1.8613821249455214, + "total_median_s": 1.8722192500717938, + "overhead_ns_per_iter": 1855.9170407243073 + } + } + }, + "scenario_b_forced_render": { + "baseline_min_s": 0.00015695812180638313, + "libs": { + "progressbar2": { + "total_min_s": 0.7645597076043487, + "total_median_s": 0.7665333333425224, + "per_update_us": 25.480091649418075 + }, + "progressbar2-fast": { + "total_min_s": 0.1489091250114143, + "total_median_s": 0.14924920815974474, + "per_update_us": 4.95840556298693 + }, + "tqdm": { + "total_min_s": 0.3326972499489784, + "total_median_s": 0.33332004211843014, + "per_update_us": 11.084676394239068 + }, + "rich": { + "total_min_s": 5.156592667102814, + "total_median_s": 5.1774807083420455, + "per_update_us": 171.8811902993669 + } + }, + "excluded": { + "alive-progress": "renders on a background timer thread; no per-update render API", + "click": "self-throttles writes (renders only when the drawn line changes); no force-every-update API" + } + }, + "scenario_c_import_time": { + "interpreter_baseline_s": 0.015367416199296713, + "libs": { + "progressbar2": { + "total_min_s": 0.016899917274713516, + "net_ms": 1.5325010754168034 + }, + "tqdm": { + "total_min_s": 0.03698108298704028, + "net_ms": 21.61366678774357 + }, + "rich": { + "total_min_s": 0.06224083295091987, + "net_ms": 46.873416751623154 + }, + "alive-progress": { + "total_min_s": 0.023891292046755552, + "net_ms": 8.52387584745884 + }, + "click": { + "total_min_s": 0.0388890840113163, + "net_ms": 23.521667812019587 + } + } + } +} diff --git a/circle.yml b/circle.yml deleted file mode 100644 index cd50d56f..00000000 --- a/circle.yml +++ /dev/null @@ -1,8 +0,0 @@ -dependencies: - pre: - - pip install -r tests/requirements.txt - -test: - override: - - py.test - diff --git a/docs/_static/progressbar-hero.svg b/docs/_static/progressbar-hero.svg new file mode 100644 index 00000000..64e8e9fc --- /dev/null +++ b/docs/_static/progressbar-hero.svg @@ -0,0 +1,37 @@ + + + + + + + Progress with clean logs + Build: 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--Build: 4% (1 of 24) |## | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 8% (2 of 24) |#### | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 12% (3 of 24) |###### | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 16% (4 of 24) |######## | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 20% (5 of 24) |########## | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 29% (7 of 24) |############## | Elapsed Time: 0:00:00 ETA: 0:00:00Build: 33% (8 of 24) |################# | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 37% (9 of 24) |################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 41% (10 of 24) |#################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 45% (11 of 24) |###################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 50% (12 of 24) |######################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 54% (13 of 24) |########################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 58% (14 of 24) |############################# | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 62% (15 of 24) |############################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8Build: 66% (16 of 24) |################################# | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 70% (17 of 24) |################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 75% (18 of 24) |##################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 83% (20 of 24) |######################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 87% (21 of 24) |########################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 91% (22 of 24) |############################################# | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 95% (23 of 24) |############################################### | Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 100% (24 of 24) |##################################################| Elapsed Time: 0:00:00 ETA: 0:00:00log: completed step 8log: completed step 16Build: 100% (24 of 24) |##################################################| Elapsed Time: 0:00:00 Time: 0:00:00 + diff --git a/docs/_static/progressbar-multibar.svg b/docs/_static/progressbar-multibar.svg new file mode 100644 index 00000000..f3e2dd5f --- /dev/null +++ b/docs/_static/progressbar-multibar.svg @@ -0,0 +1,37 @@ + + + + + + + Multiple active jobs + build 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 4% (1 of 24) |# | Elapsed Time: 0:00:00 ETA: 0:00:00test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 8% (2 of 24) |### | Elapsed Time: 0:00:00 ETA: 0:00:00test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 12% (3 of 24) |#### | Elapsed Time: 0:00:00 ETA: 0:00:00test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 16% (4 of 24) |###### | Elapsed Time: 0:00:00 ETA: 0:00:00test 0% (0 of 24) | | Elapsed Time: 0:00:00 ETA: --:--:--build 20% (5 of 24) |####### | Elapsed Time: 0:00:00 ETA: 0:00:00test 4% (1 of 24) |# | Elapsed Time: 0:00:00 ETA: 0:00:00build 25% (6 of 24) |######### | Elapsed Time: 0:00:00 ETA: 0:00:00test 8% (2 of 24) |### | Elapsed Time: 0:00:00 ETA: 0:00:00build 29% (7 of 24) |########## | Elapsed Time: 0:00:00 ETA: 0:00:00test 16% (4 of 24) |###### | Elapsed Time: 0:00:00 ETA: 0:00:00build 33% (8 of 24) |############ | Elapsed Time: 0:00:00 ETA: 0:00:00test 20% (5 of 24) |####### | Elapsed Time: 0:00:00 ETA: 0:00:00build 37% (9 of 24) |############# | Elapsed Time: 0:00:00 ETA: 0:00:00test 25% (6 of 24) |######### | Elapsed Time: 0:00:00 ETA: 0:00:00build 41% (10 of 24) |############### | Elapsed Time: 0:00:00 ETA: 0:00:00test 29% (7 of 24) |########## | Elapsed Time: 0:00:00 ETA: 0:00:00build 45% (11 of 24) |################ | Elapsed Time: 0:00:00 ETA: 0:00:00test 33% (8 of 24) |############ | Elapsed Time: 0:00:00 ETA: 0:00:00build 54% (13 of 24) |################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 45% (11 of 24) |################ | Elapsed Time: 0:00:00 ETA: 0:00:00build 58% (14 of 24) |##################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 50% (12 of 24) |################## | Elapsed Time: 0:00:00 ETA: 0:00:00build 62% (15 of 24) |###################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 54% (13 of 24) |################### | Elapsed Time: 0:00:00 ETA: 0:00:00build 66% (16 of 24) |######################## | Elapsed Time: 0:00:00 ETA: 0:00:00test 58% (14 of 24) |##################### | Elapsed Time: 0:00:00 ETA: 0:00:00build 70% (17 of 24) |######################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 66% (16 of 24) |######################## | Elapsed Time: 0:00:00 ETA: 0:00:00build 75% (18 of 24) |########################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 70% (17 of 24) |######################### | Elapsed Time: 0:00:00 ETA: 0:00:00build 79% (19 of 24) |############################ | Elapsed Time: 0:00:00 ETA: 0:00:00test 75% (18 of 24) |########################### | Elapsed Time: 0:00:00 ETA: 0:00:00build 83% (20 of 24) |############################## | Elapsed Time: 0:00:00 ETA: 0:00:00test 79% (19 of 24) |############################ | Elapsed Time: 0:00:00 ETA: 0:00:00build 87% (21 of 24) |############################### | Elapsed Time: 0:00:00 ETA: 0:00:00test 83% (20 of 24) |############################## | Elapsed Time: 0:00:00 ETA: 0:00:00build 91% (22 of 24) |################################# | Elapsed Time: 0:00:00 ETA: 0:00:00test 91% (22 of 24) |################################# | Elapsed Time: 0:00:00 ETA: 0:00:00build 95% (23 of 24) |################################## | Elapsed Time: 0:00:00 ETA: 0:00:00test 95% (23 of 24) |################################## | Elapsed Time: 0:00:00 ETA: 0:00:00build 100% (24 of 24) |####################################| Elapsed Time: 0:00:00 ETA: 0:00:00test 100% (24 of 24) |####################################| Elapsed Time: 0:00:00 ETA: 0:00:00 + diff --git a/docs/_static/progressbar-unknown-length.svg b/docs/_static/progressbar-unknown-length.svg new file mode 100644 index 00000000..515970c7 --- /dev/null +++ b/docs/_static/progressbar-unknown-length.svg @@ -0,0 +1,37 @@ + + + + + + + Unknown length + / |# | 0 Elapsed Time: 0:00:00- |# | 0 Elapsed Time: 0:00:00\ |# | 10 Elapsed Time: 0:00:00| |# | 20 Elapsed Time: 0:00:00/ |# | 30 Elapsed Time: 0:00:00- |# | 40 Elapsed Time: 0:00:00\ |# | 50 Elapsed Time: 0:00:00| |# | 60 Elapsed Time: 0:00:00/ |# | 70 Elapsed Time: 0:00:00- |# | 80 Elapsed Time: 0:00:00\ |# | 90 Elapsed Time: 0:00:00| |# | 100 Elapsed Time: 0:00:00/ |# | 110 Elapsed Time: 0:00:00| |# | 110 Elapsed Time: 0:00:00 + diff --git a/docs/_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 cc917b50..a769e40c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,5 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + # # Progress Bar documentation build configuration file, created by # sphinx-quickstart on Tue Aug 20 11:47:33 2013. @@ -10,10 +11,9 @@ # # All configuration values have a default; values that are commented out # serve to show the default. - +import datetime import os import sys -import datetime # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -39,9 +39,13 @@ 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', - 'changelog' ] +suppress_warnings = [ + 'image.nonlocal_uri', +] + +needs_sphinx = '1.4' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -55,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, - metadata.__author__, -) +project = 'Progress Bar' +project_slug: str = ''.join(project.capitalize().split()) +copyright = f'{datetime.date.today().year}, {metadata.__author__}' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = metadata.__version__ +version: str = metadata.__version__ # The full version, including alpha/beta/rc tags. -release = metadata.__version__ +release: str = metadata.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -186,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 -------------------------------------------- @@ -194,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, - metadata.__author__, 'manual'), +latex_documents: list[tuple[str, ...]] = [ + ( + 'index', + f'{project_slug}.tex', + f'{project} Documentation', + metadata.__author__, + 'manual', + ) ] # The name of an image file (relative to this directory) to place at the top of @@ -234,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, - [metadata.__author__], 1) +man_pages: list[tuple[str, str, str, list[str], int]] = [ + ( + 'index', + project_slug.lower(), + f'{project} Documentation', + [metadata.__author__], + 1, + ) ] # If true, show URL addresses after external links. @@ -248,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, - metadata.__author__, project_slug, 'One line description of project.', - 'Miscellaneous'), +texinfo_documents: list[tuple[str, ...]] = [ + ( + 'index', + project_slug, + f'{project} Documentation', + metadata.__author__, + project_slug, + 'One line description of project.', + 'Miscellaneous', + ) ] # Documents to append as an appendix to all manuals. @@ -270,10 +285,10 @@ # -- Options for Epub output --------------------------------------------- # Bibliographic Dublin Core info. -epub_title = project -epub_author = metadata.__author__ -epub_publisher = metadata.__author__ -epub_copyright = copyright +epub_title: str = project +epub_author: str = metadata.__author__ +epub_publisher: str = metadata.__author__ +epub_copyright: str = copyright # The language of the text. It defaults to the language option # or en if the language is not set. @@ -299,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 = [] @@ -326,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/examples.rst b/docs/examples.rst index 769c4d47..55091dcb 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,5 +1,8 @@ Examples =================== -.. literalinclude:: ../examples.py +The :doc:`usage` guide and README show the generated overview demos. This page +keeps the full runnable example collection in sync with ``examples.py``. + +.. literalinclude:: ../examples.py diff --git a/docs/index.rst b/docs/index.rst index 6b9b9f0d..c8ff0e92 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,28 +1,27 @@ +======================================== Welcome to Progress Bar's documentation! ======================================== -.. include:: ../README.rst - -Contents ------------------------------------------------------------------------------- - .. toctree:: - :maxdepth: 2 + :maxdepth: 4 usage examples contributing - history installation + progressbar.shortcuts progressbar.bar - progressbar.six + progressbar.base progressbar.utils progressbar.widgets + history + +.. include:: ../README.rst +****************** Indices and tables -================== +****************** * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/docs/major-version-backlog.md b/docs/major-version-backlog.md new file mode 100644 index 00000000..5c5deb9b --- /dev/null +++ b/docs/major-version-backlog.md @@ -0,0 +1,107 @@ +# Major-version backlog + +Breaking changes that the quality audit identified as worth making but that +are **intentionally deferred** to the next major version, because fixing them +now would change the public API or long-standing rendered/CLI behaviour that +users may depend on. Each item is kept working as-is in the current release +line; this document records *why* it is deferred and *where* the code lives so +the change is easy to pick up later. + +Nothing here is a bug that affects correctness of the documented API today — +those were fixed in the audit PRs. These are design debts whose fix is a +compatibility break. + +## MultiBar composes over `dict` instead of subclassing it + +`MultiBar` subclasses `dict` (`progressbar/multi.py`, +`class MultiBar(dict[str, bar.ProgressBar])`). The subclassing leaks several +surprising behaviours: an auto-vivifying `__getitem__` that creates a bar on +missing-key access, state that is dual-keyed by both label and bar object, and +`print` redirection installed by monkeypatching (`bar.print` / `self.print`). +Composition over inheritance (holding a private mapping and exposing an +explicit, documented interface) would remove the auto-vivification foot-gun and +clarify the threading/rendering API, but it changes the type's identity and the +mapping methods callers can use, so it must wait for a major version. + +## `FormatLabelBar.__call__` diamond dispatch and incompatible override + +`FormatLabelBar` (`progressbar/widgets.py`) manually dispatches to both parents +via `FormatLabel.__call__(self, ...)` and `Bar.__call__(self, ...)`, and its +`__call__` override carries a `# type: ignore` because its signature is not +compatible with the base `WidgetBase.__call__` contract. A CodeQL alert on the +incompatible override was dismissed as by-design: the diamond is deliberate and +the widths line up at runtime. Making the signatures genuinely compatible (or +folding the composition into a cooperative call chain) is a public-signature +change, so it is deferred. + +## CLI no-op `pv`-compatibility flags + +`progressbar/__main__.py` declares a `pv(1)`-compatible flag set, but only a +few (`--buffer-size`, `--eta`, `--input`/positional, `--line-mode`, `--size`) +actually feed the runtime. The rest — `--timer`, `--rate`, `--numeric`, +`--delay-start`, `--interval`, `--height`, `--width`, and friends — are parsed +and then silently ignored. They exist so `pv`-style command lines do not error +out. The next major version should either wire each flag to real behaviour or +reject unsupported flags explicitly; both are user-visible CLI changes, so they +are deferred. + +## `__next__` / `next` manual-iteration path + +`ProgressBar.__next__` (`progressbar/bar.py`) advances the bar on manual +iteration but bypasses the fast redraw gate that `update()` goes through, so +hand-rolled `next(bar)` loops render on a different schedule than the normal +paths. `next = __next__` (same file) is a Python-2-era alias kept so old code +calling `bar.next()` keeps working. Routing `__next__` through the gate and +dropping the `next` alias are behaviour/API changes, so they are deferred. + +## `ColorBase` and `WindowsColor` no-op public classes + +`ColorBase` (`progressbar/terminal/base.py`) is an abstract base that its own +docstring describes as deprecated (it only exists because `typing.NamedTuple` +cannot be used as a base for the real `Color`). `WindowsColor` (same file) is +effectively a no-op that duplicates `DummyColor`: recent Windows terminals +support ANSI, so it passes text through unstyled. Both are importable public +names, so removing them is a compatibility break for the next major version. + +## Unused `Colors` lookup indexes + +`Colors.register` (`progressbar/terminal/base.py`) populates four reverse +indexes on every registration — `by_name`, `by_lowername`, `by_hex`, and +`by_hls` — but nothing in the tree ever reads them. For the 256-colour table +that is 256×4 list appends plus the backing dicts at import time, for lookups +no code performs. Dropping the unused indexes would cut import work and memory, +but they are public class attributes, so their removal is deferred. + +## 16-colour terminals receive the `38;5;N` SGR form + +For a detected 16-colour terminal (`env.ColorSupport.XTERM`), `Color.ansi` +(`progressbar/terminal/base.py`) still emits the indexed `38;5;N` / `48;5;N` +256-colour SGR form (with `N` derived from the 16-colour palette) rather than +the canonical `30`–`37` / `90`–`97` direct codes. This is a pre-existing +convention that real terminals tolerate; switching to the direct codes changes +the exact bytes emitted for every colour on those terminals, so it is deferred. + +## Deferred `DeprecationWarning` upgrades (silent-compat policy) + +Several backwards-compatibility shims currently accept legacy usage silently +rather than warning: + +- `apply_colors(**kwargs)` (`progressbar/terminal/base.py`) swallows unknown + keyword arguments instead of rejecting them. +- Legacy aliases such as `RotatingMarker` (for `AnimatedMarker`) and + `DynamicMessage` (for `Variable`) in `progressbar/widgets.py`. +- The `%s` → `%(elapsed)s` / `%(eta)s` format shims in `Timer` / `ETA` + (`progressbar/widgets.py`). + +Emitting `DeprecationWarning` from these paths is the right long-term move, but +warnings are observable behaviour (and can break `-W error` test suites), so +the upgrade is a coordinated major-version change. + +## `deltas_to_seconds` sentinel/`ValueError` contract + +`deltas_to_seconds` (`progressbar/utils.py`) is now expressed as typed +overloads, but its runtime contract around the not-a-number / default sentinel +still leans on raising `ValueError` for a class of inputs. A cleaner redesign +(an explicit sentinel type or an `Optional`-returning overload set) would be a +signature/behaviour change for callers that catch `ValueError`, so it is +deferred to the API redesign in the next major version. 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.multi.rst b/docs/progressbar.multi.rst new file mode 100644 index 00000000..5d8b85fd --- /dev/null +++ b/docs/progressbar.multi.rst @@ -0,0 +1,7 @@ +progressbar.multi module +======================== + +.. automodule:: progressbar.multi + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.rst b/docs/progressbar.rst new file mode 100644 index 00000000..674f6b64 --- /dev/null +++ b/docs/progressbar.rst @@ -0,0 +1,31 @@ +progressbar package +=================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + progressbar.bar + progressbar.base + progressbar.multi + progressbar.shortcuts + progressbar.utils + progressbar.widgets + +Module contents +--------------- + +.. automodule:: progressbar + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.shortcuts.rst b/docs/progressbar.shortcuts.rst new file mode 100644 index 00000000..eda60479 --- /dev/null +++ b/docs/progressbar.shortcuts.rst @@ -0,0 +1,7 @@ +progressbar\.shortcuts module +============================= + +.. automodule:: progressbar.shortcuts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.six.rst b/docs/progressbar.six.rst deleted file mode 100644 index b4213402..00000000 --- a/docs/progressbar.six.rst +++ /dev/null @@ -1,7 +0,0 @@ -progressbar.six module -====================== - -.. automodule:: progressbar.six - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/progressbar.terminal.base.rst b/docs/progressbar.terminal.base.rst new file mode 100644 index 00000000..8114b8cf --- /dev/null +++ b/docs/progressbar.terminal.base.rst @@ -0,0 +1,7 @@ +progressbar.terminal.base module +================================ + +.. automodule:: progressbar.terminal.base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.colors.rst b/docs/progressbar.terminal.colors.rst new file mode 100644 index 00000000..d03706f7 --- /dev/null +++ b/docs/progressbar.terminal.colors.rst @@ -0,0 +1,7 @@ +progressbar.terminal.colors module +================================== + +.. automodule:: progressbar.terminal.colors + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.rst b/docs/progressbar.terminal.rst new file mode 100644 index 00000000..dba09353 --- /dev/null +++ b/docs/progressbar.terminal.rst @@ -0,0 +1,28 @@ +progressbar.terminal package +============================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal.os_specific + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal.base + progressbar.terminal.colors + progressbar.terminal.stream + +Module contents +--------------- + +.. automodule:: progressbar.terminal + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.stream.rst b/docs/progressbar.terminal.stream.rst new file mode 100644 index 00000000..2bb3b355 --- /dev/null +++ b/docs/progressbar.terminal.stream.rst @@ -0,0 +1,7 @@ +progressbar.terminal.stream module +================================== + +.. automodule:: progressbar.terminal.stream + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/requirements.txt b/docs/requirements.txt index 168e48f1..62d6cd8a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1 @@ --r ../requirements.txt -changelog -sphinx>=1.3 +-e.[docs,tests] diff --git a/docs/usage.rst b/docs/usage.rst index 6aa03303..cdd530a1 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -16,6 +16,21 @@ Wrapping an iterable for i in bar(range(100)): time.sleep(0.02) +Tqdm-style options +------------------------------------------------------------------------------ +:: + + import progressbar + + for item in progressbar.progressbar( + range(10), + desc='Items', + total=10, + unit='it', + postfix={'state': 'running'}, + ): + pass + Context wrapper ------------------------------------------------------------------------------ :: @@ -37,10 +52,25 @@ Combining progressbars with print output bar = progressbar.ProgressBar(redirect_stdout=True) for i in range(100): - print 'Some text', i + print('Some text', i) time.sleep(0.1) bar.update(i) +Logging integration +------------------------------------------------------------------------------ +:: + + import logging + import progressbar + + progressbar.streams.wrap_stderr() + progressbar.streams.wrap_logging() + logging.basicConfig() + + with progressbar.ProgressBar(total=10, redirect_stderr=True) as bar: + logging.warning('message above the bar') + bar.update(1) + Progressbar with unknown length ------------------------------------------------------------------------------ :: @@ -67,4 +97,3 @@ Bar with custom widgets ]) for i in bar(range(20)): time.sleep(0.1) - diff --git a/examples.py b/examples.py index a938b36b..eb2953d2 100644 --- a/examples.py +++ b/examples.py @@ -1,374 +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, AbsoluteETA, 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 - pbar.update(10 * i + 1) - pbar.finish() + 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 example2(): - class CrazyFileTransferSpeed(FileTransferSpeed): +def percentage_label_bar_example() -> None: + widgets = [progressbar.PercentageLabelBar()] + bar = progressbar.ProgressBar(widgets=widgets, max_value=10).start() + for i in range(10): + # do something + time.sleep(0.1) + bar.update(i + 1) + bar.finish() - "It's bigger between 45 and 80 percent" - def update(self, pbar): - if 45 < pbar.percentage() < 80: - return 'Bigger Now ' + FileTransferSpeed.update(self, pbar) +@example +def file_transfer_example() -> None: + widgets = [ + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), + ] + bar = progressbar.ProgressBar(widgets=widgets, max_value=1000).start() + for i in range(100): + # do something + time.sleep(0.01) + bar.update(10 * i + 1) + bar.finish() + + +@example +def custom_file_transfer_example() -> None: + class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): + """ + It's bigger between 45 and 80 percent + """ + + def update(self, bar): + if 45 < bar.percentage() < 80: + return 'Bigger Now ' + progressbar.FileTransferSpeed.update( + self, bar + ) else: - return 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 progress_with_automatic_max() -> None: + # Progressbar can guess max_value automatically. + bar = progressbar.ProgressBar() + for _ in bar(range(8)): + time.sleep(0.1) @example -def example7(): - pbar = ProgressBar() # Progressbar can guess max_value automatically. - for i in pbar(range(8)): - time.sleep(0.001) +def progress_with_unavailable_max() -> None: + # Progressbar can't guess max_value. + bar = progressbar.ProgressBar(max_value=8) + for _ in bar(i for i in range(8)): + time.sleep(0.1) @example -def example8(): - pbar = ProgressBar(max_value=8) # Progressbar can't guess max_value. - for i in pbar((i for i in range(8))): - time.sleep(0.001) +def animated_marker() -> None: + bar = progressbar.ProgressBar( + widgets=['Working: ', progressbar.AnimatedMarker()] + ) + for _ in bar(i for i in range(5)): + time.sleep(0.1) @example -def example9(): - pbar = ProgressBar(widgets=['Working: ', AnimatedMarker()]) - for i in pbar((i for i in range(5))): - time.sleep(0.001) +def filling_bar_animated_marker() -> None: + bar = progressbar.ProgressBar( + widgets=[ + progressbar.Bar( + marker=progressbar.AnimatedMarker(fill='#'), + ), + ] + ) + for _ in bar(range(15)): + time.sleep(0.1) @example -def example10(): - widgets = ['Processed: ', Counter(), ' lines (', Timer(), ')'] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(15))): - time.sleep(0.001) +def counter_and_timer() -> None: + widgets = [ + 'Processed: ', + progressbar.Counter('Counter: %(value)05d'), + ' lines (', + progressbar.Timer(), + ')', + ] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(15)): + time.sleep(0.1) @example -def example11(): - widgets = [FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(15))): - time.sleep(0.001) +def format_label() -> None: + widgets = [ + progressbar.FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)') + ] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(15)): + time.sleep(0.1) @example -def example12(): - widgets = ['Balloon: ', AnimatedMarker(markers='.oO@* ')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) +def animated_balloons() -> None: + widgets = ['Balloon: ', progressbar.AnimatedMarker(markers='.oO@* ')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) @example -def example13(): +def animated_arrows() -> None: # You may need python 3.x to see this correctly try: - widgets = ['Arrows: ', AnimatedMarker(markers='←↖↑↗→↘↓↙')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) + widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='←↖↑↗→↘↓↙')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def example14(): +def animated_filled_arrows() -> None: # You may need python 3.x to see this correctly try: - widgets = ['Arrows: ', AnimatedMarker(markers='◢◣◤◥')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) + widgets = ['Arrows: ', progressbar.AnimatedMarker(markers='◢◣◤◥')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def example15(): +def animated_wheels() -> None: # You may need python 3.x to see this correctly try: - widgets = ['Wheels: ', AnimatedMarker(markers='◐◓◑◒')] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): - time.sleep(0.001) + widgets = ['Wheels: ', progressbar.AnimatedMarker(markers='◐◓◑◒')] + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(i for i in range(24)): + time.sleep(0.1) except UnicodeError: sys.stdout.write('Unicode error: skipping example') @example -def example16(): - widgets = [FormatLabel('Bouncer: value %(value)d - '), BouncingBar()] - pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(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(), + ] + + bar = progressbar.ProgressBar(widgets=widgets) + for _ in bar(gen()): + time.sleep(0.02) @example -def example29(): - widgets = ['Test: ', Percentage(), ' | ', ETA(), ' | ', AbsoluteETA()] - pbar = ProgressBar(widgets=widgets, maxval=500).start() - for i in range(500): - time.sleep(0.01) - pbar.update(i+1) - pbar.finish() +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() -def test(): - for example in examples: - example() if __name__ == '__main__': try: - test() + test(*sys.argv[1:]) except KeyboardInterrupt: - sys.stdout('\nQuitting examples.\n') + sys.stdout.write('\nQuitting examples.\n') diff --git a/progressbar/__about__.py b/progressbar/__about__.py index 567b1d57..0e870b3f 100644 --- a/progressbar/__about__.py +++ b/progressbar/__about__.py @@ -1,4 +1,4 @@ -'''Text progress bar library for Python. +"""Text progress bar library for Python. A text progress bar is typically used to display the progress of a long running operation, providing a visual cue that processing is underway. @@ -9,17 +9,21 @@ 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__ = ''' +__description__: str = ' '.join( + """ A Python Progressbar library to provide visual (yet text based) progress to long running operations. -'''.strip() +""".strip().split(), +) __email__ = 'wolph@wol.ph' -__version__ = '3.5.1' +__version__ = '4.5.0' +#: Release date of ``__version__`` as a static string; bump on each release. +__date__ = '2024-08-29' __license__ = 'BSD' -__copyright__ = 'Copyright 2015 Rick van Hattem (Wolph)' -__url__ = 'https://github.com/WoLpH/python-progressbar' +__copyright__ = 'Copyright 2015-2026 Rick van Hattem (Wolph)' +__url__ = 'https://github.com/wolph/python-progressbar' diff --git a/progressbar/__init__.py b/progressbar/__init__.py index 951ad726..00f1c8fe 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -1,58 +1,204 @@ -from datetime import date - -from progressbar.widgets import ( - Timer, - ETA, - AdaptiveETA, - AbsoluteETA, - DataSize, - FileTransferSpeed, - AdaptiveTransferSpeed, - AnimatedMarker, - Counter, - Percentage, - FormatLabel, - SimpleProgress, - Bar, - ReverseBar, - BouncingBar, - RotatingMarker, -) +"""progressbar2 public API. -from .bar import ProgressBar, DataTransferBar -from .base import UnknownLength +Imports are lazy (PEP 562): ``import progressbar`` loads almost nothing; each +submodule and exported name is imported on first access. This keeps the import +light (in particular the widgets and the terminal/color tables are only loaded +when actually used) while preserving the full public API. +""" +import importlib +import typing +# Re-exported as a static module attribute (kept out of ``__all__`` for +# backwards compatibility). ``__date__`` used to be recomputed at import time +# via ``date.today()``; it now mirrors the release date from ``__about__``. from .__about__ import ( __author__, + __date__ as __date__, __version__, ) -__date__ = str(date.today()) +if typing.TYPE_CHECKING: + # Eager imports for type checkers only; loaded lazily at runtime by + # __getattr__ below. Names appear in __all__ so they read as re-exports. + from .algorithms import ( + DoubleExponentialMovingAverage, + ExponentialMovingAverage, + SmoothingAlgorithm, + ) + from .bar import DataTransferBar, NullBar, ProgressBar + from .base import UnknownLength + from .fast import FastProgressBar + from .multi import MultiBar, SortKey + from .shortcuts import progressbar + from .terminal.stream import LineOffsetStreamWrapper + from .utils import len_color, streams + from .widgets import ( + ETA, + AbsoluteETA, + AdaptiveETA, + AdaptiveTransferSpeed, + AnimatedMarker, + Bar, + BouncingBar, + Counter, + CurrentTime, + DataSize, + DynamicMessage, + FileTransferSpeed, + FormatCustomText, + FormatLabel, + FormatLabelBar, + GranularBar, + JobStatusBar, + MultiProgressBar, + MultiRangeBar, + Percentage, + PercentageLabelBar, + Postfix, + ReverseBar, + RotatingMarker, + SimpleProgress, + SmoothingETA, + Timer, + UnitProgress, + Variable, + VariableMixin, + ) + +#: Submodules accessible as ``progressbar.``. +_SUBMODULES: frozenset[str] = frozenset( + { + 'algorithms', + 'bar', + 'base', + 'env', + 'fast', + 'multi', + 'shortcuts', + 'terminal', + 'utils', + 'widgets', + } +) + +#: Exported name -> submodule it lives in. +_NAME_TO_MODULE: dict[str, str] = { + 'DoubleExponentialMovingAverage': 'algorithms', + 'ExponentialMovingAverage': 'algorithms', + 'SmoothingAlgorithm': 'algorithms', + 'DataTransferBar': 'bar', + 'NullBar': 'bar', + 'ProgressBar': 'bar', + 'FastProgressBar': 'fast', + 'UnknownLength': 'base', + 'MultiBar': 'multi', + 'SortKey': 'multi', + 'progressbar': 'shortcuts', + 'LineOffsetStreamWrapper': 'terminal.stream', + 'len_color': 'utils', + 'streams': 'utils', + 'ETA': 'widgets', + 'AbsoluteETA': 'widgets', + 'AdaptiveETA': 'widgets', + 'AdaptiveTransferSpeed': 'widgets', + 'AnimatedMarker': 'widgets', + 'Bar': 'widgets', + 'BouncingBar': 'widgets', + 'Counter': 'widgets', + 'CurrentTime': 'widgets', + 'DataSize': 'widgets', + 'DynamicMessage': 'widgets', + 'FileTransferSpeed': 'widgets', + 'FormatCustomText': 'widgets', + 'FormatLabel': 'widgets', + 'FormatLabelBar': 'widgets', + 'GranularBar': 'widgets', + 'JobStatusBar': 'widgets', + 'MultiProgressBar': 'widgets', + 'MultiRangeBar': 'widgets', + 'Percentage': 'widgets', + 'Postfix': 'widgets', + 'PercentageLabelBar': 'widgets', + 'ReverseBar': 'widgets', + 'RotatingMarker': 'widgets', + 'SimpleProgress': 'widgets', + 'SmoothingETA': 'widgets', + 'Timer': 'widgets', + 'UnitProgress': 'widgets', + 'Variable': 'widgets', + 'VariableMixin': 'widgets', +} + + +def __getattr__(name: str) -> typing.Any: + """Lazily import submodules and exported names on first access.""" + if name in _SUBMODULES: + module = importlib.import_module(f'.{name}', __name__) + globals()[name] = module # cache so __getattr__ runs only once + return module + + module_name = _NAME_TO_MODULE.get(name) + if module_name is None: + raise AttributeError(f'module {__name__!r} has no attribute {name!r}') + value = getattr(importlib.import_module(f'.{module_name}', __name__), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__) | _SUBMODULES) + + +#: Canonical export list, kept equal to ``sorted(_NAME_TO_MODULE)`` (the single +#: source of truth for lazily re-exported names) plus the eagerly imported +#: dunders. Held as a static literal so type checkers can see the re-exports; +#: ``tests/test_init_exports.py`` asserts it stays in sync with the mapping. __all__ = [ - 'AbstractWidget', - 'Widget', - 'WidgetHFill', - 'Timer', 'ETA', - 'AdaptiveETA', 'AbsoluteETA', - 'DataSize', - 'FileTransferSpeed', + 'AdaptiveETA', 'AdaptiveTransferSpeed', 'AnimatedMarker', - 'Counter', - 'Percentage', - 'FormatLabel', - 'SimpleProgress', 'Bar', - 'ReverseBar', 'BouncingBar', - 'UnknownLength', - 'ProgressBar', + 'Counter', + 'CurrentTime', + 'DataSize', 'DataTransferBar', - 'format_updatable', + 'DoubleExponentialMovingAverage', + 'DynamicMessage', + 'ExponentialMovingAverage', + 'FastProgressBar', + 'FileTransferSpeed', + 'FormatCustomText', + 'FormatLabel', + 'FormatLabelBar', + 'GranularBar', + 'JobStatusBar', + 'LineOffsetStreamWrapper', + 'MultiBar', + 'MultiProgressBar', + 'MultiRangeBar', + 'NullBar', + 'Percentage', + 'PercentageLabelBar', + 'Postfix', + 'ProgressBar', + 'ReverseBar', 'RotatingMarker', + 'SimpleProgress', + 'SmoothingAlgorithm', + 'SmoothingETA', + 'SortKey', + 'Timer', + 'UnitProgress', + '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..9523544a --- /dev/null +++ b/progressbar/__main__.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +import argparse +import contextlib +import pathlib +import sys +import time +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 _default_widgets( + filesize_available: bool, +) -> list[progressbar.widgets.WidgetBase | str]: + if filesize_available: + return [ + progressbar.Percentage(), + ' ', + progressbar.Bar(), + ' ', + progressbar.Timer(), + ' ', + progressbar.FileTransferSpeed(), + ] + return [ + progressbar.SimpleProgress(), + ' ', + progressbar.DataSize(), + ' ', + progressbar.Timer(), + ] + + +def _append_widget_group( + widgets: list[progressbar.widgets.WidgetBase | str], + group: typing.Sequence[progressbar.widgets.WidgetBase | str], +) -> None: + if widgets: + widgets.append(' ') + widgets.extend(group) + + +def _build_widgets( + args: argparse.Namespace, + filesize_available: bool, +) -> list[progressbar.widgets.WidgetBase | str]: + if args.quiet: + return [] + + requested_widgets = [ + (args.progress, [progressbar.Percentage(), ' ', progressbar.Bar()]), + (args.bytes, [progressbar.DataSize()]), + (args.timer, [progressbar.Timer()]), + (args.eta, [progressbar.AdaptiveETA()]), + (args.fineta, [progressbar.AbsoluteETA()]), + (args.rate or args.average_rate, [progressbar.FileTransferSpeed()]), + ] + selected_widgets = [ + group for selected, group in requested_widgets if selected + ] + if not selected_widgets: + return _default_widgets(filesize_available) + + widgets: list[progressbar.widgets.WidgetBase | str] = [] + for group in selected_widgets: + _append_widget_group(widgets, group) + + return widgets + + +def _sleep_for_rate_limit( + rate_limit: int | None, + transferred: int, + started_at: float, + now: float | None = None, +) -> None: + if not rate_limit: + return + now = time.monotonic() if now is None else now + expected_elapsed = transferred / rate_limit + actual_elapsed = now - started_at + delay = expected_elapsed - actual_elapsed + if delay > 0: + time.sleep(delay) + + +def main(argv: list[str] | None = None) -> None: # noqa: C901 + """ + Main function for the `progressbar` command. + + 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 + + widgets = _build_widgets(args, filesize_available) + progress_bar_class: type[progressbar.ProgressBar] = ( + progressbar.NullBar if args.quiet else progressbar.ProgressBar + ) + + # Initialize the progress bar + bar = progress_bar_class( + widgets=widgets, + max_value=total_size if filesize_available else None, + max_error=False, + line_breaks=True if args.numeric else None, + ) + + # Data processing and updating the progress bar + buffer_size = ( + size_to_bytes(args.buffer_size) if args.buffer_size else 1024 + ) + rate_limit = ( + size_to_bytes(args.rate_limit) if args.rate_limit else None + ) + started_at = time.monotonic() + total_transferred = 0 + + bar.start() + 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) + _sleep_for_rate_limit( + rate_limit, + total_transferred, + started_at, + ) + + 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 3893cd45..7cf1622d 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -1,132 +1,647 @@ -from __future__ import division, absolute_import, with_statement -import sys +from __future__ import annotations + +import abc +import collections.abc +import contextlib +import functools +import importlib +import itertools +import logging import math +import os +import sys +import time +import timeit +import typing import warnings -from datetime import datetime, timedelta -import collections +import weakref +from copy import deepcopy +from datetime import datetime +from types import FrameType + +from python_utils import converters + +import progressbar.env +import progressbar.terminal +import progressbar.terminal.stream + +from . import ( + base, + utils, +) +from .terminal import os_specific + +try: + # Optional native accelerator, shipped as the ``progressbar2[fast]`` extra + # (the separate ``speedups`` package). When importable, the iterator path + # uses it automatically; otherwise we fall back to the pure-Python gate. + # Loaded via importlib so type checkers don't try to resolve the optional + # compiled module when it is absent. + _FastBarIterator = importlib.import_module( + 'speedups.progressbar', + ).FastBarIterator +except Exception: # pragma: no cover - environmental (absent / ABI mismatch) + _FastBarIterator = None + + +@functools.cache +def _load_widgets() -> typing.Any: + """Import the widgets module lazily (and once). + + The full-bar code needs ``widgets``, but the lean fast path must not pull + it in (it drags the terminal/colour tables). Imported via importlib so the + deferred load doesn't read as a static ``bar -> widgets`` import cycle. + + Cached with ``functools.cache`` so full-bar render sites don't pay the + ``import_module`` lookup on every call; the module object is resolved once + on first use and reused thereafter. + """ + return importlib.import_module('progressbar.widgets') + + +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 = NumberT | type[base.UnknownLength] | None + +T = typing.TypeVar('T') + + +class ProgressBarMixinBase(abc.ABC): + _started = False + _finished = False + _last_update_time: float | None = 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()` + #: (typed loosely as Any to avoid a static bar->widgets import cycle; the + #: public ``progressbar()`` shortcut keeps the precise WidgetBase typing). + widgets: collections.abc.MutableSequence[typing.Any] + #: 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: str | None + #: Suffix the progressbar with the given string + suffix: str | None + #: 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: dict[str, typing.Any] + #: Custom length function for multibyte characters such as CJK. A plain + #: ``Callable[[str], int]`` is used (rather than a bound-method signature) + #: because mypy and pyright disagree on the more precise form. + custom_len: collections.abc.Callable[[str], int] + #: The time the progress bar was started + initial_start_time: datetime | None + #: The interval to poll for updates in seconds if there are updates + poll_interval: float | None + #: 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: NumberT | None + #: Value at the last actual redraw (internal; used by the update gate's + #: pixel check, kept separate from the public `previous_value`) + _last_drawn_value: NumberT | None + #: 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: datetime | None + #: The time `start()` was called or iteration started. + start_time: datetime | None + #: 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: dict[str, typing.Any] + + def get_last_update_time(self) -> datetime | None: + if self._last_update_time: + return datetime.fromtimestamp(self._last_update_time) + else: + return None -from . import widgets -from . import six -from . import utils -from . import base + def set_last_update_time(self, value: datetime | None): + if value: + self._last_update_time = time.mktime(value.timetuple()) + else: + self._last_update_time = None + last_update_time = property(get_last_update_time, set_last_update_time) -class ProgressBarMixinBase(object): - def __init__(self, **kwargs): + def __init__(self, **kwargs: typing.Any): # noqa: B027 pass - def start(self): - pass + def start(self, **kwargs: typing.Any): + self._started = True - def update(self, value=None): + def update(self, value: ValueT = None): # noqa: B027 pass def finish(self): # pragma: no cover - pass + self._finished = True + + def __del__(self): + if not self._finished and self._started: # pragma: no cover + # We're not using contextlib.suppress here because during teardown + # contextlib is not available anymore. 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__ + + def data(self) -> dict[str, typing.Any]: # pragma: no cover + raise NotImplementedError() + + def started(self) -> bool: + return self._finished or self._started + + def finished(self) -> bool: + return self._finished + + +class ProgressBarBase(collections.abc.Iterable[NumberT], ProgressBarMixinBase): + _index_counter = itertools.count() + index: int = -1 + label: str = '' + def __init__(self, **kwargs: typing.Any): + # Guard against the cooperative chain (or an old-style subclass + # making several explicit parent __init__ calls) reaching this + # method more than once per instance: `index` keeps its class + # default of -1 until the first construction, so each bar + # consumes exactly one counter value. + if self.index == -1: + 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 - ProgressBarMixinBase.__init__(self, **kwargs) + self.is_ansi_terminal = progressbar.env.is_ansi_terminal(fd) + self.is_terminal = progressbar.env.is_terminal(fd, is_terminal) + self.line_breaks = self._determine_line_breaks(line_breaks) + self.enable_colors = self._determine_enable_colors(enable_colors) + + super().__init__(**kwargs) + + def _apply_line_offset( + self, + fd: base.TextIO, + line_offset: int, + ) -> base.TextIO: + if line_offset: + return progressbar.terminal.stream.LineOffsetStreamWrapper( + line_offset, + fd, + ) + else: + return fd + + def _determine_line_breaks(self, line_breaks: bool | None) -> bool | None: + if line_breaks is None: + return progressbar.env.env_flag( + 'PROGRESSBAR_LINE_BREAKS', + not self.is_terminal, + ) + else: + return line_breaks + + def _determine_enable_colors( + self, + enable_colors: progressbar.env.ColorSupport | None, + ) -> progressbar.env.ColorSupport: + """ + Determines the color support for the progress bar. + + This method checks the `enable_colors` parameter and the environment + variables `PROGRESSBAR_ENABLE_COLORS` and `FORCE_COLOR` to determine + the color support. + + If `enable_colors` is: + - `None`, it checks the environment variables and the terminal + compatibility to ANSI. + - `True`, it sets the color support to XTERM_256. + - `False`, it sets the color support to NONE. + - For different values that are not instances of + `progressbar.env.ColorSupport`, it raises a ValueError. + + Args: + enable_colors (progressbar.env.ColorSupport | None): The color + support setting from the user. It can be None, True, False, + or an instance of `progressbar.env.ColorSupport`. + + Returns: + progressbar.env.ColorSupport: The determined color support. + + Raises: + ValueError: If `enable_colors` is not None, True, False, or an + instance of `progressbar.env.ColorSupport`. + """ + color_support: progressbar.env.ColorSupport + if enable_colors is None: + colors = ( + progressbar.env.env_flag('PROGRESSBAR_ENABLE_COLORS'), + progressbar.env.env_flag('FORCE_COLOR'), + self.is_ansi_terminal, + ) + + for color_enabled in colors: + if color_enabled is not None: + if color_enabled: + color_support = progressbar.env.COLOR_SUPPORT + else: + color_support = progressbar.env.ColorSupport.NONE + break + else: + color_support = progressbar.env.ColorSupport.NONE + + elif enable_colors is True: + color_support = progressbar.env.ColorSupport.XTERM_256 + elif enable_colors is False: + color_support = progressbar.env.ColorSupport.NONE + elif isinstance(enable_colors, progressbar.env.ColorSupport): + color_support = enable_colors + else: + raise ValueError(f'Invalid color support value: {enable_colors}') + + return color_support + + def print(self, *args: typing.Any, **kwargs: typing.Any) -> None: + print(*args, file=self.fd, **kwargs) + + def start(self, **kwargs: typing.Any): + os_specific.set_console_mode() + super().start(**kwargs) + + def update(self, *args: typing.Any, **kwargs: typing.Any) -> None: + super().update(*args, **kwargs) + + line: str = converters.to_unicode(self._format_line()) + if not self.enable_colors: + line = utils.no_color(line) + + line = line.rstrip() + '\n' if self.line_breaks else '\r' + line + + try: # pragma: no cover + self.fd.write(line) + except UnicodeEncodeError: # pragma: no cover + # ``fd`` is a text stream, so write an ASCII-safe *str*: encode + # with 'replace' to drop un-encodable characters, then decode + # back. Writing the raw bytes here would raise ``TypeError``. + self.fd.write(line.encode('ascii', 'replace').decode('ascii')) + + def finish( + self, + *args: typing.Any, + **kwargs: typing.Any, + ) -> None: # pragma: no cover + os_specific.reset_console_mode() + + if self._finished: + return + + end = kwargs.pop('end', '\n') + super().finish(*args, **kwargs) - def update(self, *args, **kwargs): - ProgressBarMixinBase.update(self, *args, **kwargs) - self.fd.write('\r' + self._format_line()) + if end and not self.line_breaks: + self.fd.write(end) - def finish(self, *args, **kwargs): # pragma: no cover - ProgressBarMixinBase.finish(self, *args, **kwargs) - self.fd.write('\n') + self.fd.flush() + + def _format_line(self): + "Joins the widgets and justifies the line." + widgets = ''.join(self._to_unicode(self._format_widgets())) + + if self.left_justify: + return widgets.ljust(self.term_width) + else: + return widgets.rjust(self.term_width) + def _format_widgets(self): + widgets = _load_widgets() -class ResizableMixin(DefaultFdMixin): - def __init__(self, term_width=None, **kwargs): - DefaultFdMixin.__init__(self, **kwargs) + 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) + + +class ResizableMixin(ProgressBarMixinBase): + def __init__(self, term_width: int | None = None, **kwargs: typing.Any): + super().__init__(**kwargs) self.signal_set = False if term_width: self.term_width = term_width - else: - try: + else: # pragma: no cover + with contextlib.suppress(Exception): self._handle_resize() - import signal - signal.signal(signal.SIGWINCH, self._handle_resize) + _ResizeRegistry.install(self) self.signal_set = True - except: # pragma: no cover - pass - - def _handle_resize(self, signum=None, frame=None): - 'Tries to catch resize signals sent from the terminal.' - w, h = utils.get_terminal_size() + 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 - DefaultFdMixin.finish(self) + super().finish() if self.signal_set: - try: - import signal - signal.signal(signal.SIGWINCH, signal.SIG_DFL) - except: # pragma no cover - pass + with contextlib.suppress(Exception): + _ResizeRegistry.uninstall(self) + self.signal_set = False class StdRedirectMixin(DefaultFdMixin): - def __init__(self, redirect_stderr=False, redirect_stdout=False, **kwargs): - DefaultFdMixin.__init__(self, **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, + ): + super().__init__(**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 - if self.redirect_stderr: - self._stderr = sys.stderr - sys.stderr = six.StringIO() - + def start(self, *args: typing.Any, **kwargs: typing.Any): if self.redirect_stdout: - self._stdout = sys.stdout - sys.stdout = six.StringIO() - - def update(self, value=None): - 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() - - if self.redirect_stdout and sys.stdout.tell(): - self.fd.write('\r' + ' ' * self.term_width + '\r') - self._stdout.write(sys.stdout.getvalue()) - self._stdout.flush() - sys.stdout = six.StringIO() + utils.streams.wrap_stdout() - DefaultFdMixin.update(self, value=value) + if self.redirect_stderr: + utils.streams.wrap_stderr() - def finish(self): - DefaultFdMixin.finish(self) + self._stdout = utils.streams.original_stdout + self._stderr = utils.streams.original_stderr - if self.redirect_stderr: - self._stderr.write(sys.stderr.getvalue()) - sys.stderr = self._stderr + self.stdout = utils.streams.stdout + self.stderr = utils.streams.stderr - if self.redirect_stdout: - self._stdout.write(sys.stdout.getvalue()) - sys.stdout = self._stdout + utils.streams.start_capturing(self) + super().start(*args, **kwargs) + def update(self, value: NumberT | None = None): + cleared = not self.line_breaks and utils.streams.needs_clear() + if cleared: + self.fd.write('\r' + ' ' * self.term_width + '\r') -class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase): + 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') + super().update(value=value) - '''The ProgressBar class which updates and prints the bar. + def finish(self, end='\n'): + try: + super().finish(end=end) + finally: + # Always release the global stream-wrapping state, even when + # the final render raises; a leaked listener would corrupt + # every later progressbar in the process. + utils.streams.stop_capturing(self) + if self.redirect_stdout: + utils.streams.unwrap_stdout() + + if self.redirect_stderr: + utils.streams.unwrap_stderr() + + +class ProgressBar( + StdRedirectMixin, + ResizableMixin, + ProgressBarBase, +): + """The ProgressBar class which updates and prints the bar. + + Args: + min_value (int): The minimum/start value for the progress bar + max_value (int): The maximum/end value for the progress bar. + Defaults to `_DEFAULT_MAXVAL` + widgets (list): The widgets to render, defaults to the result of + `default_widget()` + left_justify (bool): Justify to the left if `True` or the right if + `False` + initial_value (int): The value to start with + poll_interval (float): The update interval in seconds. + Note that if your widgets include timers or animations, the actual + interval may be smaller (faster updates). Also note that updates + never happens faster than `min_poll_interval` which can be used for + reduced output in logs + min_poll_interval (float): The minimum update interval in seconds. + The bar will _not_ be updated faster than this, despite changes in + the progress, unless `force=True`. This is limited to be at least + `_MINIMUM_UPDATE_INTERVAL`. If available, it is also bound by the + environment variable PROGRESSBAR_MINIMUM_UPDATE_INTERVAL + widget_kwargs (dict): The default keyword arguments for widgets + custom_len (function): Method to override how the line width is + calculated. When using non-latin characters the width + calculation might be off by default + max_error (bool): When True the progressbar will raise an error if it + goes beyond it's set max_value. Otherwise the max_value is simply + raised when needed + prefix (str): Prefix the progressbar with the given string + suffix (str): Prefix the progressbar with the given string + variables (dict): User-defined variables variables that can be used + from a label using `format='{variables.my_var}'`. These values can + be updated using `bar.update(my_var='newValue')` This can also be + used to set initial values for variables' widgets + line_offset (int): The number of lines to offset the progressbar from + your current line. This is useful if you have other output or + multiple progressbars A common way of using it is like: >>> progress = ProgressBar().start() >>> for i in range(100): - ... progress.update(i+1) + ... progress.update(i + 1) ... # do something - ... >>> progress.finish() You can also use a ProgressBar as an iterator: @@ -136,7 +651,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 @@ -153,61 +667,226 @@ 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. + """ + + _iterable: collections.abc.Iterator | None + + _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: float | None = None + paused: bool = False + + def __init__( + self, + min_value: NumberT = 0, + max_value: ValueT = None, + widgets: collections.abc.Sequence[typing.Any] | None = None, + left_justify: bool = True, + initial_value: NumberT = 0, + poll_interval: float | None = None, + widget_kwargs: dict[str, typing.Any] | None = None, + custom_len: collections.abc.Callable[[str], int] = utils.len_color, + max_error=True, + prefix=None, + suffix=None, + variables=None, + min_poll_interval=None, + desc: str | None = None, + total: ValueT = None, + unit: str = 'it', + unit_scale: bool = False, + postfix: typing.Any = None, + **kwargs, + ): + """Initializes a progress bar with sane defaults.""" + super().__init__(**kwargs) + + max_value, poll_interval = self._apply_deprecated_aliases( + max_value, poll_interval, kwargs + ) - 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''' - StdRedirectMixin.__init__(self, **kwargs) - ResizableMixin.__init__(self, **kwargs) - ProgressBarBase.__init__(self, **kwargs) - if not max_value and kwargs.get('maxval'): - warnings.warn('The usage of `maxval` is deprecated, please use ' - '`max_value` instead', DeprecationWarning) + if max_value is None and total is not None: + # tqdm-style alias for `max_value` + max_value = total + if prefix is None and desc is not None: + # tqdm-style alias for `prefix` + prefix = f'{desc}: ' + + if max_value and min_value > typing.cast(NumberT, max_value): + raise ValueError( + 'Max value needs to be bigger than the min value', + ) + self.min_value = min_value + # Legacy issue, `max_value` can be `None` before execution. After + # that it either has a value or is `UnknownLength` + self.max_value = max_value # type: ignore + self.max_error = max_error + + self.widgets = self._copy_widgets(widgets) + + self.unit = unit + self.unit_scale = unit_scale + # Auto-append a Postfix widget in start() when `postfix` is used + # with the default widgets; explicit widget lists are left alone. + self._auto_postfix = widgets is None and postfix is not None + self._auto_postfix_added = False + + 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() + + self._setup_poll_intervals(poll_interval, min_poll_interval) + self._seed_variables(variables) + if postfix is not None: + self.variables['postfix'] = postfix + + def _apply_deprecated_aliases( + self, + max_value: ValueT, + poll_interval: float | None, + kwargs: dict[str, typing.Any], + ) -> tuple[ValueT, float | None]: + """Resolve the deprecated ``maxval``/``poll`` keyword aliases. + + Emits a :py:class:`DeprecationWarning` for each legacy name that is + used without its modern counterpart and returns the (possibly updated) + ``(max_value, poll_interval)`` pair. + """ + 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') - self.min_value = min_value - self.max_value = max_value - self.widgets = widgets - self.left_justify = left_justify + return max_value, poll_interval + + def _copy_widgets( + self, widgets: collections.abc.Sequence[typing.Any] | None + ) -> list[typing.Any]: + """Return a fresh widget list, deep-copying the copy-safe widgets. + + Only copy a widget if it's safe to copy. Most widgets are, so that is + assumed to be true unless a widget opts out with ``copy = False``. + """ + result: list[typing.Any] = [] + for widget in widgets or []: + if getattr(widget, 'copy', True): + widget = deepcopy(widget) + result.append(widget) + return result - self._iterable = None + def _setup_poll_intervals( + self, + poll_interval: float | None, + min_poll_interval: float | None, + ) -> None: + """Convert the poll intervals to seconds and clamp the minimum. + + Convert a given timedelta to a floating point number as the 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 + + def _seed_variables(self, variables: dict[str, typing.Any] | None) -> None: + """Seed the user-defined variables dict and scan widgets for names. + + Builds the ``variables`` mapping used by ``Variable``/``FormatWidget`` + and registers a ``None`` placeholder for every ``VariableMixin`` widget + whose name isn't already supplied. + """ + self.variables = utils.AttributeDict(variables or {}) + if self.widgets: + widgets_module = _load_widgets() + + 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 + # Value at the last actual redraw; used internally by the update gate's + # pixel check (distinct from the public `previous_value`). + self._last_drawn_value = None 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 + self._last_update_timer = timeit.default_timer() + # Fast-path "next update" gate. The common iteration only re-enters + # the redraw machinery when value reaches `_next_update`. `_gate_step` + # is a closed-loop estimate of iterations per `min_poll_interval`, + # calibrated in `update()` from the value/time elapsed between redraws + # (tracked by `_last_drawn_value`/`_last_update_timer`). It starts at 1 + # so the gate forces an `update()` every iteration until a real timing + # measurement (or the back-off doubling) grows the step, so slow + # iterators (where time advances between calls) are never skipped + # before that. + self._next_update = 0 + self._gate_step = 1 + self._gate_enabled = True + 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 @@ -236,37 +915,51 @@ def percentage(self): 25.0 >>> progress.max_value = None >>> progress.percentage - ''' + """ if self.max_value is None or self.max_value is base.UnknownLength: return None elif self.max_value: todo = self.value - self.min_value - total = self.max_value - self.min_value - percentage = todo / total + total = self.max_value - self.min_value # type: ignore + percentage = 100.0 * todo / total else: - percentage = 1 - - return percentage * 100 - - def 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 - # 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) + percentage = 100.0 + + return percentage + + def data(self) -> dict[str, typing.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. + + This is a pure snapshot of the current state: it performs no timing + side effects. The redraw path stamps the update timestamps via + :py:meth:`_mark_update` before the widgets read them. + """ + elapsed = self.last_update_time - self.start_time # type: ignore + total_seconds_elapsed = utils.deltas_to_seconds(elapsed) return dict( # The maximum value (can be None with iterators) max_value=self.max_value, @@ -285,233 +978,611 @@ def data(self): # The seconds since the bar started total_seconds_elapsed=total_seconds_elapsed, # The seconds since the bar started modulo 60 - seconds_elapsed=(elapsed.seconds % 60) + - (elapsed.microseconds / 1000000.), + seconds_elapsed=(elapsed.seconds % 60) + + (elapsed.microseconds / 1000000.0), # The minutes since the bar started modulo 60 minutes_elapsed=(elapsed.seconds / 60) % 60, # The hours since the bar started modulo 24 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, + unit=self.unit, + unit_scale=self.unit_scale, + # 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): + widgets = _load_widgets() + 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.AnimatedMarker(), - ' ', widgets.Counter(), - ' ', 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 TypeError: - if self.max_value is None: - self.max_value = base.UnknownLength - else: - self.max_value = max_value + except TypeError: # pragma: no cover + self.max_value = base.UnknownLength self._iterable = iter(iterable) return self def __iter__(self): - return self + # Dispatch to the optional native iterator when available, else the + # pure-Python generator. The native path counts in C and syncs + # `value`/`previous_value` only at redraw crossings (so they lag + # mid-loop, like `tqdm.n`), beating the per-iteration attribute writes + # the pure-Python path pays to keep them live every iteration. + if ( + _FastBarIterator is not None + and self._iterable is not None + and not os.environ.get('PROGRESSBAR_DISABLE_FASTPATH') + ): + return _FastBarIterator(self, self._iterable) + return self._iter_python() + + def _iter_python(self): + # Single generator (see issue #212): a `break`/exception in the loop + # body triggers `GeneratorExit`, letting us finish and restore any + # redirected streams. The integer gate keeps the common iteration to + # an increment + compare + store; the slow path (`update`) makes the + # real redraw decision and recomputes the gate. + # + # Value semantics MUST match pre-change behavior: `start()` draws 0% + # and the FIRST item is yielded at `value == min_value` (no increment), + # so during the body for item i (0-indexed), `bar.value == i` — NOT + # i+1. Only subsequent items increment. The peek-first structure below + # reproduces this without a per-iteration branch. + iterable = self._iterable if self._iterable is not None else iter(()) + try: + if self.start_time is None: + self.start() + iterator = iter(iterable) + try: + item = next(iterator) + except StopIteration: + self.finish() + return + yield item # first item at value == min_value (matches old code) + value = self.value + next_update = value + update = self.update + # `_gate_enabled` is set once in `start()` and never mutated during + # iteration, so hoist it to a local and drop the per-iteration + # attribute load on the hot path. + gate_enabled = self._gate_enabled + for item in iterator: + value += 1 + # When the gate is disabled, call `update()` every iteration so + # behaviour is byte-identical to the ungated bar. When enabled, + # only re-enter `update()` once value reaches the threshold. + # The step starts at 1, so until a real measurement grows it + # this still calls `update()` every iteration and lets + # `_needs_update()` make the real redraw decision. Calling + # `update()` (rather than pre-setting `self.value`) lets it + # record the prior value in the public `previous_value`, + # preserving its original semantics. + if not gate_enabled or value >= next_update: + update(value) + next_update = self._next_update + else: + # Gated out: advance bar.value AND previous_value (exactly + # as update() would) without entering the redraw machinery, + # so reads of bar.previous_value mid-loop stay identical to + # the original every-iteration semantics. The gate's pixel + # reference is the separate `_last_drawn_value`. + self.previous_value = self.value + self.value = value + yield item + self.finish() + except GeneratorExit: + self.finish(dirty=True) + raise + + # --- Native accelerator protocol (used by speedups.FastBarIterator) ------ + # The C iterator counts items itself and calls back here only at gate + # crossings, reusing the existing gate/redraw/calibration machinery so the + # redraw cadence is identical to `_iter_python`. + + def _fast_begin(self) -> None: + """Start the bar (draws 0%, sets `_next_update`/`_gate_enabled`).""" + if self.start_time is None: + self.start() + + def _fast_tick(self, value: int) -> None: + """Handle a redraw crossing: redraw-if-due and recompute the gate.""" + self.update(value) + + def _fast_end(self) -> None: + """Finish normally (draws 100%, restores streams) on exhaustion.""" + self.finish() + + def _fast_end_dirty(self) -> None: + """Finish dirty on early break/exception (restores streams).""" + self.finish(dirty=True) 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, six.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 + "Updates the ProgressBar by adding a new value." + return self.increment(value) - widget_output = widget(self, data, portion) - width -= len(widget_output) - result[index] = widget_output - - 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._last_drawn_value + + # Update if the value increment is large enough to add more bars + # to the progressbar (according to the current terminal width). + # While the state is incomplete -- nothing drawn yet, no usable + # terminal width, no (nonzero) max value -- there is no width + # threshold to compute and no redraw is due; those guards mirror + # what a `suppress(Exception)` used to swallow here. Anything else + # failing in this math is a real bug and should propagate instead + # of silently stopping redraws. + if ( + self.value is not None + and self._last_drawn_value is not None + and self.term_width + and self.max_value + ): + divisor: float = self.max_value / self.term_width # type: ignore + value_divisor = self.value // divisor + pvalue_divisor = self._last_drawn_value // divisor + if value_divisor != pvalue_divisor: + return True + # No need to redraw yet + return False + + def _gate_skips( + self, value: ValueT, force: bool, variables_changed: bool + ) -> bool: + """Whether the fast-path gate should skip this update() call entirely. + + Only skips while enabled, never for forced draws, variable changes, + or a `None` (tick) value, and only while the value is still below the + `_next_update` threshold. + """ + return ( + self._gate_enabled + and not force + and not variables_changed + and value is not None + and self.value < self._next_update + ) - 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 _draw_and_recalibrate( + self, value: ValueT, variables_changed: bool, force: bool + ) -> None: + """Redraw if due, then resize the gate's next-update threshold. + + On a redraw, `_gate_step` is calibrated to ~one `min_poll_interval` + window of iterations, measured from the value/time elapsed since the + previous redraw (snapshotted here before the draw overwrites + `_last_drawn_value`/`_last_update_timer` — so the gate needs no extra + copies of those quantities). If we passed the threshold but no redraw + was due (the loop sped up), back off by doubling the step. + """ + if self._needs_update() or variables_changed or force: + prev_value = self._last_drawn_value + prev_timer = self._last_update_timer + try: + self._update_parents(value) # _mark_update refreshes timer + finally: + # `_last_drawn_value` is the value at the last *redraw* (the + # pixel reference for `_needs_update`); set in finally so it + # advances even if a draw raised. + self._last_drawn_value = self.value + if self._gate_enabled: + interval = self._last_update_timer - prev_timer + if ( + prev_value is not None + and interval > 0 + and self.value > prev_value + ): + self._gate_step = max( + 1, + int( + (self.value - prev_value) + * self.min_poll_interval + / interval + ), + ) + self._next_update = self.value + self._gate_step + elif self._gate_enabled and value is not None: + self._gate_step = max(1, self._gate_step * 2) + self._next_update = self.value + self._gate_step + + 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 base.UnknownLength: + # `isinstance(value, (int, float))` already excludes both `None` and + # the `UnknownLength` sentinel (a class, not a numeric instance), so + # the earlier explicit `is not None`/`is not UnknownLength` clauses + # were redundant. + if 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) + + # `previous_value` keeps its original public meaning: the value + # before this update() call. The gate uses a separate private + # `_last_drawn_value` (set on redraw) for its pixel check. self.previous_value = self.value self.value = value - if not self._needs_update(): + # Save the updated values for dynamic messages (skip the call and the + # empty-dict iteration on the common no-kwargs path). + variables_changed = self._update_variables(kwargs) if kwargs else False + + if self._gate_skips(value, force, variables_changed): return - self.updates += 1 - ResizableMixin.update(self, value=value) - ProgressBarBase.update(self, value=value) - StdRedirectMixin.update(self, value=value) + self._draw_and_recalibrate(value, variables_changed, force) - def start(self, max_value=None): - '''Starts measuring time, and prints the bar at 0%. + 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 _mark_update(self) -> None: + """Stamp the wall-clock and perf-counter time of the current redraw. + + Called from the draw path (:py:meth:`_update_parents`) before the + widgets read ``last_update_time``. ``_last_update_timer`` feeds the + poll-interval gate in :py:meth:`_needs_update` and the cadence + calibration in :py:meth:`_draw_and_recalibrate`; ``_last_update_time`` + backs the public ``last_update_time`` property used by + elapsed-time/ETA widgets. Kept out of :py:meth:`data` so that method is + a pure snapshot with no timing side effects. + """ + self._last_update_time = time.time() + self._last_update_timer = timeit.default_timer() + + def _update_parents(self, value: ValueT): + self.updates += 1 + # Stamp the redraw timestamps before formatting widgets so that + # `data()`/`last_update_time` reflect this redraw and the gate + # calibration in `_draw_and_recalibrate` measures the interval up to + # this draw (it snapshots `_last_update_timer` before this call and + # reads it again afterwards). + self._mark_update() + # Cooperative dispatch through the MRO + # (StdRedirectMixin -> DefaultFdMixin -> ProgressBarMixinBase). The + # `value` is passed by keyword so the intermediate `*args, **kwargs` + # and `value=None` signatures interoperate. + super().update(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 - self.max_value = max_value or self.max_value if self.max_value is None: self.max_value = self._DEFAULT_MAXVAL - # Constructing the default widgets is only done when we know max_value - if self.widgets is None: + # Constructing the default widgets is only done when we know + # max_value + if not self.widgets: self.widgets = self.default_widgets() + if self._auto_postfix and not self._auto_postfix_added: + self.widgets.append(_load_widgets().Postfix()) + self._auto_postfix_added = True + + self._init_prefix() + self._init_suffix() + self._calculate_poll_interval() + if ( + os.environ.get('PROGRESSBAR_DISABLE_FASTPATH') + or not self.min_poll_interval + ): + self._gate_enabled = False + + try: + self._verify_max_value() + + # Timing state must be populated before `_started` becomes + # observable: a concurrent reader (MultiBar's render thread) that + # sees `started()` True calls `update(force=True)`, and `update()` + # re-enters `start()` whenever `start_time` is still None -- + # running the stream-capturing path twice. + now = datetime.now() + self.start_time = self.initial_start_time or now + self.last_update_time = now + self._last_update_timer = timeit.default_timer() + + # Cooperative dispatch through the MRO + # (StdRedirectMixin -> DefaultFdMixin -> ProgressBarMixinBase); + # ResizableMixin/ProgressBarBase define no `start` and are + # skipped. This runs *after* all widget/state setup so that + # `_started` (set by ProgressBarMixinBase.start) only becomes + # observable once `widgets` is fully populated. Otherwise a + # concurrent reader (e.g. MultiBar's render thread) could see + # `started()` True with an empty widget list and crash in + # `_label_bar`'s `assert bar.widgets`. The 0% draw below still + # happens at the same point, after stream/console setup. + super().start(max_value=max_value) + + self.update(self.min_value, force=True) + except Exception: + # A failed start must not leak global stream-wrapping state + # (registered listeners, redirected stdout/stderr): run the + # finish chain suppressed and re-raise the original error. + with contextlib.suppress(Exception): + super().finish(end='') + raise + return self + + def _init_suffix(self): + if self.suffix: + widgets = _load_widgets() + + 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: + widgets = _load_widgets() + + self.widgets.insert( + 0, + widgets.FormatLabel(self.prefix, new_style=True), + ) + # Unset the prefix variable after applying so an extra start() + # won't keep copying it + self.prefix = None + + def _verify_max_value(self): + if ( + self.max_value is not base.UnknownLength + and self.max_value is not None + and self.max_value < 0 # type: ignore + ): + raise ValueError(f'max_value out of range, got {self.max_value!r}') + + def _calculate_poll_interval(self) -> None: + self.num_intervals = max(100, self.term_width) for widget in self.widgets: - interval = getattr(widget, 'INTERVAL', None) + interval: int | float | None = utils.deltas_to_seconds( + getattr(widget, 'INTERVAL', None), + default=None, + ) if interval is not None: self.poll_interval = min( self.poll_interval or interval, interval, ) - self.num_intervals = max(100, self.term_width) - self.next_update = 0 - - if self.max_value is not base.UnknownLength: - if self.max_value < 0: - raise ValueError('Value out of range') - self.update_interval = self.max_value / self.num_intervals - - self.start_time = self.last_update_time = datetime.now() - self.update(self.min_value) - - return self - - def finish(self): - 'Puts the ProgressBar bar in the finished state.' + 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 - self.end_time = datetime.now() - self.update(self.max_value) + try: + if not dirty: + self.end_time = datetime.now() + self.update(self.max_value, force=True) + finally: + # Run the finish chain even when the final render raises, so a + # failing widget cannot leak the global stream-wrapping state. + # Cooperative dispatch through the MRO + # (StdRedirectMixin -> DefaultFdMixin -> ResizableMixin -> + # ProgressBarMixinBase). Ordering note: the SIGWINCH uninstall in + # ResizableMixin.finish now runs *before* the stream unwrap in + # StdRedirectMixin.finish (previously it ran after). The two + # subsystems are independent, so the observable result is + # unchanged. + super().finish(end=end) - StdRedirectMixin.finish(self) - ResizableMixin.finish(self) - ProgressBarBase.finish(self) + @property + def currval(self): + """ + Legacy method to make progressbar-2 compatible with the original + progressbar package. + """ + warnings.warn( + 'The usage of `currval` is deprecated, please use `value` instead', + DeprecationWarning, + stacklevel=1, + ) + return self.value class DataTransferBar(ProgressBar): - '''A progress bar with sensible defaults for downloads etc. + """A progress bar with sensible defaults for downloads etc. This assumes that the values its given are numbers of bytes. - ''' - # Base class defaults to 100, but that makes no sense here - _DEFAULT_MAXVAL = base.UnknownLength + """ def default_widgets(self): + widgets = _load_widgets() + if self.max_value: return [ widgets.Percentage(), - ' of ', widgets.DataSize('max_value'), - ' ', widgets.Bar(), - ' ', widgets.Timer(), - ' ', widgets.AdaptiveETA(), + ' of ', + widgets.DataSize('max_value'), + ' ', + widgets.Bar(), + ' ', + widgets.Timer(), + ' ', + widgets.SmoothingETA(), ] else: return [ widgets.AnimatedMarker(), - ' ', widgets.DataSize(), - ' ', widgets.Timer(), + ' ', + widgets.DataSize(), + ' ', + widgets.Timer(), ] + + +class NullBar(ProgressBar): + """ + Progress bar that does absolutely nothing. Useful for single verbosity + flags. + """ + + 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 index 2808c258..6c80c19d 100644 --- a/progressbar/base.py +++ b/progressbar/base.py @@ -1,15 +1,29 @@ -from .six import with_metaclass +from __future__ import annotations + +from typing import IO, TextIO class FalseMeta(type): - def __bool__(self): # pragma: no cover + @classmethod + def __bool__(cls) -> bool: # pragma: no cover return False - def __cmp__(self, other): # pragma: no cover - return -1 - __nonzero__ = __bool__ +class UnknownLength(metaclass=FalseMeta): + pass -class UnknownLength(with_metaclass(FalseMeta, object)): +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..2c92b6ed --- /dev/null +++ b/progressbar/env.py @@ -0,0 +1,234 @@ +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. A `TERM` that is itself a + truecolor terminal (see `TRUECOLOR_TERMS`) also enables 24 bit + support. If they contain `256`, we will enable 256 color/8 bit + support. If they match a known ANSI terminal (see `ANSI_TERM_RE`, + e.g. `xterm-color`, `screen`, `tmux`, `konsole`, `rxvt`, `linux`), we + will enable 16 color support. Otherwise, we 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', + ) + + # Precedence order is significant: an interactive Jupyter kernel and + # the Windows console probe each take priority over (and short-circuit) + # the env-var scan below. + if JUPYTER: + return cls._from_jupyter() + elif os.name == 'nt': + return cls._from_windows() + + return cls._from_term_variables(variables) + + @classmethod + def _from_jupyter(cls) -> ColorSupport: + """Jupyter notebooks always support true color.""" + return cls.XTERM_TRUECOLOR + + @classmethod + def _from_windows(cls) -> ColorSupport: # pragma: no cover + """Detect color support from the Windows console mode. + + We can't reliably detect true color support on Windows, so we assume + it is supported when 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 + + @classmethod + def _from_term_variables( + cls, + variables: tuple[str, ...], + ) -> ColorSupport: + """Pick the highest color support advertised by the terminal env vars. + + The first `truecolor`/`24bit` value wins immediately; otherwise the + highest depth seen across all variables is returned. A generic truthy + flag such as `FORCE_COLOR=1` carries no depth and implies full color + support, analogous to the Jupyter handling above. + """ + 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 value in TRUECOLOR_TERMS: + # A TERM name that itself guarantees a 24-bit terminal. + support = max(cls.XTERM_TRUECOLOR, support) + elif '256' in value: + support = max(cls.XTERM_256, support) + elif ANSI_TERM_RE.match(value): + # Any recognized ANSI terminal (xterm-color, screen, tmux, + # konsole, rxvt, linux, ...) advertises at least 16 colors, + # matching is_ansi_terminal()'s use of the same pattern. + support = max(cls.XTERM, support) + elif env_flag(variable, default=False): + 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. Only the errors a stream legitimately + # produces are treated as "not a terminal": OSError (real I/O), + # ValueError (closed/detached file objects) and AttributeError + # (objects without isatty). Anything else is a bug and propagates. + with contextlib.suppress(OSError, ValueError, AttributeError): + 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: + # If we do get a TTY we know this is a valid terminal. Streams can + # legitimately fail with OSError (real I/O), ValueError (closed or + # detached file objects) or AttributeError (no isatty at all); + # anything else is a bug and propagates. + try: + is_terminal = fd.isatty() + except (OSError, ValueError, AttributeError): + is_terminal = False + + return is_terminal + + +JUPYTER = bool( + os.environ.get('JUPYTER_COLUMNS') + or os.environ.get('JUPYTER_LINES') + or os.environ.get('JPY_PARENT_PID') +) +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 +) + +#: TERM values that on their own guarantee a truecolor-capable terminal, so +#: 24-bit color still engages when ``COLORTERM`` is stripped (e.g. over ssh +#: or sudo). Limited to names that *are* the terminal; generic values such as +#: ``xterm-256color`` are used by plenty of 256-only emulators. +TRUECOLOR_TERMS: frozenset[str] = frozenset({'xterm-kitty', 'xterm-ghostty'}) + +# Defined after ANSI_TERM_RE / TRUECOLOR_TERMS because from_env() reads them. +COLOR_SUPPORT = ColorSupport.from_env() diff --git a/progressbar/fast.py b/progressbar/fast.py new file mode 100644 index 00000000..56bd4a07 --- /dev/null +++ b/progressbar/fast.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import typing +from collections.abc import Callable +from datetime import datetime, timedelta + +from . import ( + bar as bar_module, + base, +) + +#: Optional native line-formatter hook. Left as ``None`` so the pure-Python +#: ``_pure_format_fast_line`` below is used by default. When set to a callable +#: it takes precedence in :py:meth:`FastProgressBar._format_line`, letting the +#: ``speedups`` package — or any caller — swap in a faster/custom formatter. +#: This is a supported extension point, exercised by +#: ``test_fast_format_line_uses_native_hook``. +_format_fast_line: Callable[[FastProgressBar], str] | None = None + +#: Spinner frames cycled for unknown-length bars: bar, forward slash, dash, +#: back slash. A plain (non-raw) literal so the escape is a single ``\`` and +#: the string is exactly four characters. +_SPINNER_FRAMES: str = '|/-\\' + + +def _format_seconds(seconds: float) -> str: + """Render elapsed/ETA seconds as H:MM:SS, matching the Timer widget.""" + return str(timedelta(seconds=int(seconds))) + + +def _pure_format_fast_line(bar: FastProgressBar) -> str: + """Build the whole status line directly (no widgets, no data() dict).""" + value = bar.value + min_value = bar.min_value + max_value = bar.max_value + width = bar.term_width + elapsed = bar._fast_elapsed() + elapsed_text = _format_seconds(elapsed) + prefix = bar.prefix or '' + suffix = bar.suffix or '' + + known = max_value not in (None, base.UnknownLength) + if known: + total = max_value - min_value # type: ignore[operator] + # Clamp progress to the total so an over-shooting value (e.g. a forced + # render past max_value with max_error=False) can't produce a negative + # ETA or a bar that overflows its width. + done = min(value - min_value, total) + pct = 100.0 * done / total if total else 100.0 + count = f'({value} of {max_value})' + if done > 0 and elapsed > 0: + eta = _format_seconds(elapsed * (total - done) / done) + else: + eta = '--:--:--' + left = f'{pct:3.0f}% {count} ' + right = f' Elapsed Time: {elapsed_text} ETA: {eta}' + inner = max(width - len(left) - len(right) - 2, 0) + filled = int(inner * done / total) if total else inner + barstr = '|' + '#' * filled + ' ' * (inner - filled) + '|' + return f'{prefix}{left}{barstr}{right}{suffix}' + + # Unknown length: spinner + count + elapsed (no bar/eta). + spinner = _SPINNER_FRAMES[int(elapsed * 4) % len(_SPINNER_FRAMES)] + item_count = value - min_value + 1 + return ( + f'{prefix}{spinner} {item_count} Elapsed Time: {elapsed_text}{suffix}' + ) + + +class FastProgressBar(bar_module.ProgressBar): + """A lean ProgressBar whose render bypasses the widget system. + + Reuses the full ProgressBar lifecycle (the next-update gate, the native + iterator, stream redirect, resize, start/update/finish) and overrides only + the render with a fixed formatter, so the common case is import- and + render-cheap. Output stays close to the default look without the gradient. + """ + + def default_widgets(self) -> list[typing.Any]: + # No widgets: the fixed formatter renders everything. + return [] + + def _fast_elapsed(self) -> float: + if self.start_time is None: + return 0.0 + end = self.end_time or self._fast_now() + return max((end - self.start_time).total_seconds(), 0.0) + + def _fast_now(self) -> datetime: + return datetime.now() + + def _format_line(self) -> str: + formatter = _format_fast_line or _pure_format_fast_line + return formatter(self) + + def _init_prefix(self) -> None: + # Label is rendered inline by the formatter; don't inject a widget. + pass + + def _init_suffix(self) -> None: + # Label is rendered inline by the formatter; don't inject a widget. + pass diff --git a/progressbar/multi.py b/progressbar/multi.py new file mode 100644 index 00000000..0e0628dd --- /dev/null +++ b/progressbar/multi.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import collections.abc +import enum +import importlib +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 + +# MultiBar renders full (widget) progress bars from background threads. Warm +# the widgets module now -- single-threaded, at module load, which only happens +# when MultiBar is actually used (this module is imported lazily), so the fast +# path and a bare ``import progressbar`` stay widgets-free. Pre-warming here +# means a child bar's first start() doesn't import widgets inside a render +# thread and race MultiBar._label_bar's ``assert bar.widgets``. +importlib.import_module('progressbar.widgets') + +SortKeyFunc = collections.abc.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]): + """Render and manage multiple progressbars from background threads. + + On a clean context-manager exit the multibar waits for its render + thread via :meth:`join`. By default (``join_timeout=None``) that wait + is unbounded, so a bar that never finishes blocks the program forever. + Pass ``join_timeout`` (seconds, or a :class:`datetime.timedelta`) to + bound that wait: once it elapses any still-unfinished bars are + abandoned and the render thread -- a daemon -- is left running so the + program can exit. The default preserves the historical wait-forever + behavior. + """ + + 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 + #: Seconds to wait for the render thread on a clean context-manager + # exit before abandoning unfinished bars. `None` waits forever. + join_timeout: 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: ( + collections.abc.Mapping[str, bar.ProgressBar] + | collections.abc.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, + *, + join_timeout: timedelta | float | 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.join_timeout = python_utils.delta_to_seconds_or_none( + join_timeout, + ) + + 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__() + + bar_items: typing.Iterable[tuple[str, bar.ProgressBar]] + if bars is None: + bar_items = () + elif isinstance(bars, collections.abc.Mapping): + bar_items = typing.cast( + typing.Iterable[tuple[str, bar.ProgressBar]], + bars.items(), + ) + else: + bar_items = bars + + for key, progress in bar_items: + self[key] = progress + + def __setitem__(self, key: str, bar: bar.ProgressBar) -> None: + """Add a progressbar to the multibar.""" + if bar.label != key or not key: # pragma: no branch + bar.label = key + + if not ( + isinstance(bar.fd, stream.LastLineStream) + and bar.fd.stream is self.fd + ): + bar.fd = stream.LastLineStream(self.fd) + + bar.paused = True + # Essentially `bar.print = self.print`, but `mypy` doesn't like that + bar.print = self.print # type: ignore + + # 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) -> bar.ProgressBar: + """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, + ) -> collections.abc.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(): + yield 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, + ) -> collections.abc.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: + yield 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) -> None: + self._thread_finished.set() + self.join(timeout=timeout) + + def get_sorted_bars(self) -> list[bar.ProgressBar]: + # 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) -> MultiBar: + 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: + # Bound the wait so a never-finishing bar cannot hang a clean + # exit; `join_timeout=None` keeps the historical forever-wait. + self.join(timeout=self.join_timeout) + if self._thread is not None: + # The timeout elapsed with bars unfinished: signal the + # render thread to shut down instead of leaving the daemon + # looping (and writing) until interpreter exit. + self.stop(timeout=self.update_interval) + 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..459c0f71 --- /dev/null +++ b/progressbar/shortcuts.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import collections.abc +import os +import typing + +from . import ( + bar, + fast as fast_module, +) + +if typing.TYPE_CHECKING: + from . import widgets as widgets_module + +T = typing.TypeVar('T') + + +def progressbar( + iterator: collections.abc.Iterable[T], + min_value: bar.NumberT = 0, + max_value: bar.ValueT = None, + widgets: collections.abc.Sequence[widgets_module.WidgetBase | str] + | None = None, + prefix: str | None = None, + suffix: str | None = None, + fast: bool | None = None, + desc: str | None = None, + total: bar.ValueT = None, + unit: str = 'it', + unit_scale: bool = False, + postfix: typing.Any = None, + **kwargs: typing.Any, +) -> collections.abc.Iterator[T]: + # Auto-dispatch to the lean FastProgressBar for the simple, common case; + # anything that needs the full widget machinery uses ProgressBar. The + # tqdm-style `desc` (a prefix) and `total` (a max_value) render fine on + # the fast path, but units and postfixes are widgets and need the full + # bar. + use_fast = ( + widgets is None + and fast is not False + and not kwargs.get('variables') + and unit == 'it' + and not unit_scale + and postfix is None + and not os.environ.get('PROGRESSBAR_DISABLE_FASTPATH') + ) + cls = fast_module.FastProgressBar if use_fast else bar.ProgressBar + progressbar_ = cls( + min_value=min_value, + max_value=max_value, + widgets=widgets, + prefix=prefix, + suffix=suffix, + desc=desc, + total=total, + unit=unit, + unit_scale=unit_scale, + postfix=postfix, + **kwargs, + ) + return iter(progressbar_(iterator)) diff --git a/progressbar/six.py b/progressbar/six.py deleted file mode 100644 index 8e104e7b..00000000 --- a/progressbar/six.py +++ /dev/null @@ -1,57 +0,0 @@ -'''Library to make differences between Python 2 and 3 transparent''' -import sys - -__all__ = [ - 'StringIO', - 'basestring', -] - -try: - from cStringIO import StringIO -except ImportError: # pragma: no cover - try: - from StringIO import StringIO - except ImportError: - from io import StringIO - -PY3 = sys.version_info[0] == 3 - -if PY3: # pragma: no cover - basestring = str -else: # pragma: no cover - import __builtin__ - basestring = __builtin__.basestring - -# Copied from the public six library: ----------------------------------------- - -# Copyright (c) 2010-2015 Benjamin Peterson -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - - -def with_metaclass(meta, *bases): - """Create a base class with a metaclass.""" - # This requires a bit of explanation: the basic idea is to make a dummy - # metaclass for one level of class instantiation that replaces itself with - # the actual metaclass. - class metaclass(meta): - - def __new__(cls, name, this_bases, d): - return meta(name, bases, d) - return type.__new__(metaclass, 'temporary_class', (), {}) diff --git a/progressbar/terminal/__init__.py b/progressbar/terminal/__init__.py new file mode 100644 index 00000000..037cce63 --- /dev/null +++ b/progressbar/terminal/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +from .base import * # noqa F403 +from .stream import * # noqa F403 diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py new file mode 100644 index 00000000..796c5f6d --- /dev/null +++ b/progressbar/terminal/base.py @@ -0,0 +1,628 @@ +from __future__ import annotations + +import abc +import collections.abc +import colorsys +import enum +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 + +from .. import ( + base as pbase, + env, +) + +# Re-exported for backwards compatibility (previously consumed by the removed +# ``_CPR`` cursor-position helper; guarded by the API snapshot). The redundant +# alias marks the re-export as intentional so it is not stripped as unused. +from .os_specific import getch as 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) + + +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: 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, strict=False) + ) + + 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: + # Threshold each channel at half intensity so a mid-range channel + # sets its bit. ``int(c / 255)`` was only ever 1 at exactly 255, which + # collapsed almost every colour (e.g. maroon 128,0,0) to black. + red = int(self.red >= 128) + green = int(self.green >= 128) + blue = int(self.blue >= 128) + 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) -> str | None: + if ( + env.COLOR_SUPPORT is env.ColorSupport.XTERM_TRUECOLOR + ): # pragma: no branch + return f'2;{self.rgb.red};{self.rgb.green};{self.rgb.blue}' + + # A true 16-colour terminal must not be handed a 256-colour index, + # so translate through to_ansi_16 there. Everywhere else prefer the + # registered xterm index (``is not None`` so index 0/Black counts): + # rendering an SGR at all means the caller decided colours are + # wanted (e.g. forced via ``enable_colors``), even when the global + # detection reported no support. + if env.COLOR_SUPPORT is env.ColorSupport.XTERM: + color = self.rgb.to_ansi_16 + elif self.xterm is not None: + color = self.xterm + elif ( + env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 + ): # pragma: no branch + color = self.rgb.to_ansi_256 + 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, list[Color]]] = defaultdict(list) + by_lowername: ClassVar[defaultdict[str, list[Color]]] = defaultdict(list) + by_hex: ClassVar[defaultdict[str, list[Color]]] = defaultdict(list) + by_rgb: ClassVar[defaultdict[RGB, list[Color]]] = defaultdict(list) + by_hls: ClassVar[defaultdict[HSL, list[Color]]] = defaultdict(list) + by_xterm: ClassVar[dict[int, Color]] = dict() + + @classmethod + def register( + cls, + rgb: RGB, + hls: HSL | None = None, + name: str | None = None, + xterm: int | None = 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: collections.abc.Callable[[Color, Color, float], Color] | None + colors: tuple[Color, ...] + + def __init__( + self, + *colors: Color, + interpolate: ( + collections.abc.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 = 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: typing.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) + + def __call__( # pyright: ignore[reportIncompatibleMethodOverride] + self, + text: str, + *args: typing.Any, + ) -> str: + if self._color.ansi is None: + # No usable color representation for this terminal (e.g. color + # support is NONE): leave the text unstyled instead of emitting + # a malformed escape code containing the literal string 'None'. + return text + return super().__call__(text, *args) + + @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..1da55c8a --- /dev/null +++ b/progressbar/terminal/colors.py @@ -0,0 +1,1092 @@ +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, 19), '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, 19), 'DarkGreen', 22) +deep_sky_blue4 = Colors.register( + RGB(0, 95, 95), + HSL(180, 100, 19), + 'DeepSkyBlue4', + 23, +) +deep_sky_blue4 = Colors.register( + RGB(0, 95, 135), + HSL(198, 100, 26), + 'DeepSkyBlue4', + 24, +) +deep_sky_blue4 = Colors.register( + RGB(0, 95, 175), + HSL(207, 100, 34), + 'DeepSkyBlue4', + 25, +) +dodger_blue3 = Colors.register( + RGB(0, 95, 215), + HSL(213, 100, 42), + 'DodgerBlue3', + 26, +) +dodger_blue2 = Colors.register( + RGB(0, 95, 255), + HSL(218, 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(162, 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(194, 100, 34), + 'DeepSkyBlue3', + 31, +) +deep_sky_blue3 = Colors.register( + RGB(0, 135, 215), + HSL(202, 100, 42), + 'DeepSkyBlue3', + 32, +) +dodger_blue1 = Colors.register( + RGB(0, 135, 255), + HSL(208, 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(153, 100, 34), + 'SpringGreen3', + 35, +) +dark_cyan = Colors.register( + RGB(0, 175, 135), + HSL(166, 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(191, 100, 42), + 'DeepSkyBlue2', + 38, +) +deep_sky_blue1 = Colors.register( + RGB(0, 175, 255), + HSL(199, 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(147, 100, 42), + 'SpringGreen3', + 41, +) +spring_green2 = Colors.register( + RGB(0, 215, 135), + HSL(158, 100, 42), + 'SpringGreen2', + 42, +) +cyan3 = Colors.register(RGB(0, 215, 175), HSL(169, 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(189, 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(142, 100, 50), + 'SpringGreen2', + 47, +) +spring_green1 = Colors.register( + RGB(0, 255, 135), + HSL(152, 100, 50), + 'SpringGreen1', + 48, +) +medium_spring_green = Colors.register( + RGB(0, 255, 175), + HSL(161, 100, 50), + 'MediumSpringGreen', + 49, +) +cyan2 = Colors.register(RGB(0, 255, 215), HSL(171, 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, 19), 'DarkRed', 52) +deep_pink4 = Colors.register( + RGB(95, 0, 95), + HSL(300, 100, 19), + 'DeepPink4', + 53, +) +purple4 = Colors.register(RGB(95, 0, 135), HSL(282, 100, 26), 'Purple4', 54) +purple4 = Colors.register(RGB(95, 0, 175), HSL(273, 100, 34), 'Purple4', 55) +purple3 = Colors.register(RGB(95, 0, 215), HSL(267, 100, 42), 'Purple3', 56) +blue_violet = Colors.register( + RGB(95, 0, 255), + HSL(262, 100, 50), + 'BlueViolet', + 57, +) +orange4 = Colors.register(RGB(95, 95, 0), HSL(60, 100, 19), '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, 53), + 'SlateBlue3', + 61, +) +slate_blue3 = Colors.register( + RGB(95, 95, 215), + HSL(240, 60, 61), + 'SlateBlue3', + 62, +) +royal_blue1 = Colors.register( + RGB(95, 95, 255), + HSL(240, 100, 69), + 'RoyalBlue1', + 63, +) +chartreuse4 = Colors.register( + RGB(95, 135, 0), + HSL(78, 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, 53), + 'SteelBlue', + 67, +) +steel_blue3 = Colors.register( + RGB(95, 135, 215), + HSL(220, 60, 61), + 'SteelBlue3', + 68, +) +cornflower_blue = Colors.register( + RGB(95, 135, 255), + HSL(225, 100, 69), + 'CornflowerBlue', + 69, +) +chartreuse3 = Colors.register( + RGB(95, 175, 0), + HSL(87, 100, 34), + 'Chartreuse3', + 70, +) +dark_sea_green4 = Colors.register( + RGB(95, 175, 95), + HSL(120, 33, 53), + 'DarkSeaGreen4', + 71, +) +cadet_blue = Colors.register( + RGB(95, 175, 135), + HSL(150, 33, 53), + 'CadetBlue', + 72, +) +cadet_blue = Colors.register( + RGB(95, 175, 175), + HSL(180, 33, 53), + 'CadetBlue', + 73, +) +sky_blue3 = Colors.register( + RGB(95, 175, 215), + HSL(200, 60, 61), + 'SkyBlue3', + 74, +) +steel_blue1 = Colors.register( + RGB(95, 175, 255), + HSL(210, 100, 69), + 'SteelBlue1', + 75, +) +chartreuse3 = Colors.register( + RGB(95, 215, 0), + HSL(93, 100, 42), + 'Chartreuse3', + 76, +) +pale_green3 = Colors.register( + RGB(95, 215, 95), + HSL(120, 60, 61), + 'PaleGreen3', + 77, +) +sea_green3 = Colors.register( + RGB(95, 215, 135), + HSL(140, 60, 61), + 'SeaGreen3', + 78, +) +aquamarine3 = Colors.register( + RGB(95, 215, 175), + HSL(160, 60, 61), + 'Aquamarine3', + 79, +) +medium_turquoise = Colors.register( + RGB(95, 215, 215), + HSL(180, 60, 61), + 'MediumTurquoise', + 80, +) +steel_blue1 = Colors.register( + RGB(95, 215, 255), + HSL(195, 100, 69), + 'SteelBlue1', + 81, +) +chartreuse2 = Colors.register( + RGB(95, 255, 0), + HSL(98, 100, 50), + 'Chartreuse2', + 82, +) +sea_green2 = Colors.register( + RGB(95, 255, 95), + HSL(120, 100, 69), + 'SeaGreen2', + 83, +) +sea_green1 = Colors.register( + RGB(95, 255, 135), + HSL(135, 100, 69), + 'SeaGreen1', + 84, +) +sea_green1 = Colors.register( + RGB(95, 255, 175), + HSL(150, 100, 69), + 'SeaGreen1', + 85, +) +aquamarine1 = Colors.register( + RGB(95, 255, 215), + HSL(165, 100, 69), + 'Aquamarine1', + 86, +) +dark_slate_gray2 = Colors.register( + RGB(95, 255, 255), + HSL(180, 100, 69), + '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(318, 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(286, 100, 34), + 'DarkMagenta', + 91, +) +dark_violet = Colors.register( + RGB(135, 0, 215), + HSL(278, 100, 42), + 'DarkViolet', + 92, +) +purple = Colors.register(RGB(135, 0, 255), HSL(272, 100, 50), 'Purple', 93) +orange4 = Colors.register(RGB(135, 95, 0), HSL(42, 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, 53), + 'MediumPurple3', + 97, +) +medium_purple3 = Colors.register( + RGB(135, 95, 215), + HSL(260, 60, 61), + 'MediumPurple3', + 98, +) +slate_blue1 = Colors.register( + RGB(135, 95, 255), + HSL(255, 100, 69), + '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, 53), 'Grey53', 102) +light_slate_grey = Colors.register( + RGB(135, 135, 175), + HSL(240, 20, 61), + 'LightSlateGrey', + 103, +) +medium_purple = Colors.register( + RGB(135, 135, 215), + HSL(240, 50, 69), + '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(74, 100, 34), 'Yellow4', 106) +dark_olive_green3 = Colors.register( + RGB(135, 175, 95), + HSL(90, 33, 53), + 'DarkOliveGreen3', + 107, +) +dark_sea_green = Colors.register( + RGB(135, 175, 135), + HSL(120, 20, 61), + 'DarkSeaGreen', + 108, +) +light_sky_blue3 = Colors.register( + RGB(135, 175, 175), + HSL(180, 20, 61), + 'LightSkyBlue3', + 109, +) +light_sky_blue3 = Colors.register( + RGB(135, 175, 215), + HSL(210, 50, 69), + 'LightSkyBlue3', + 110, +) +sky_blue2 = Colors.register( + RGB(135, 175, 255), + HSL(220, 100, 76), + 'SkyBlue2', + 111, +) +chartreuse2 = Colors.register( + RGB(135, 215, 0), + HSL(82, 100, 42), + 'Chartreuse2', + 112, +) +dark_olive_green3 = Colors.register( + RGB(135, 215, 95), + HSL(100, 60, 61), + 'DarkOliveGreen3', + 113, +) +pale_green3 = Colors.register( + RGB(135, 215, 135), + HSL(120, 50, 69), + 'PaleGreen3', + 114, +) +dark_sea_green3 = Colors.register( + RGB(135, 215, 175), + HSL(150, 50, 69), + 'DarkSeaGreen3', + 115, +) +dark_slate_gray3 = Colors.register( + RGB(135, 215, 215), + HSL(180, 50, 69), + 'DarkSlateGray3', + 116, +) +sky_blue1 = Colors.register( + RGB(135, 215, 255), + HSL(200, 100, 76), + 'SkyBlue1', + 117, +) +chartreuse1 = Colors.register( + RGB(135, 255, 0), + HSL(88, 100, 50), + 'Chartreuse1', + 118, +) +light_green = Colors.register( + RGB(135, 255, 95), + HSL(105, 100, 69), + '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(327, 100, 34), + 'DeepPink4', + 125, +) +medium_violet_red = Colors.register( + RGB(175, 0, 135), + HSL(314, 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(289, 100, 42), + 'DarkViolet', + 128, +) +purple = Colors.register(RGB(175, 0, 255), HSL(281, 100, 50), 'Purple', 129) +dark_orange3 = Colors.register( + RGB(175, 95, 0), + HSL(33, 100, 34), + 'DarkOrange3', + 130, +) +indian_red = Colors.register( + RGB(175, 95, 95), + HSL(0, 33, 53), + 'IndianRed', + 131, +) +hot_pink3 = Colors.register( + RGB(175, 95, 135), + HSL(330, 33, 53), + 'HotPink3', + 132, +) +medium_orchid3 = Colors.register( + RGB(175, 95, 175), + HSL(300, 33, 53), + 'MediumOrchid3', + 133, +) +medium_orchid = Colors.register( + RGB(175, 95, 215), + HSL(280, 60, 61), + 'MediumOrchid', + 134, +) +medium_purple2 = Colors.register( + RGB(175, 95, 255), + HSL(270, 100, 69), + 'MediumPurple2', + 135, +) +dark_goldenrod = Colors.register( + RGB(175, 135, 0), + HSL(46, 100, 34), + 'DarkGoldenrod', + 136, +) +light_salmon3 = Colors.register( + RGB(175, 135, 95), + HSL(30, 33, 53), + 'LightSalmon3', + 137, +) +rosy_brown = Colors.register( + RGB(175, 135, 135), + HSL(0, 20, 61), + 'RosyBrown', + 138, +) +grey63 = Colors.register(RGB(175, 135, 175), HSL(300, 20, 61), 'Grey63', 139) +medium_purple2 = Colors.register( + RGB(175, 135, 215), + HSL(270, 50, 69), + '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, 53), + 'DarkKhaki', + 143, +) +navajo_white3 = Colors.register( + RGB(175, 175, 135), + HSL(60, 20, 61), + 'NavajoWhite3', + 144, +) +grey69 = Colors.register(RGB(175, 175, 175), HSL(0, 0, 69), '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(71, 100, 42), 'Yellow3', 148) +dark_olive_green3 = Colors.register( + RGB(175, 215, 95), + HSL(80, 60, 61), + 'DarkOliveGreen3', + 149, +) +dark_sea_green3 = Colors.register( + RGB(175, 215, 135), + HSL(90, 50, 69), + '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(79, 100, 50), + 'GreenYellow', + 154, +) +dark_olive_green2 = Colors.register( + RGB(175, 255, 95), + HSL(90, 100, 69), + '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(333, 100, 42), + 'DeepPink3', + 161, +) +deep_pink3 = Colors.register( + RGB(215, 0, 135), + HSL(322, 100, 42), + 'DeepPink3', + 162, +) +magenta3 = Colors.register( + RGB(215, 0, 175), + HSL(311, 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(291, 100, 50), + 'Magenta2', + 165, +) +dark_orange3 = Colors.register( + RGB(215, 95, 0), + HSL(27, 100, 42), + 'DarkOrange3', + 166, +) +indian_red = Colors.register( + RGB(215, 95, 95), + HSL(0, 60, 61), + 'IndianRed', + 167, +) +hot_pink3 = Colors.register( + RGB(215, 95, 135), + HSL(340, 60, 61), + 'HotPink3', + 168, +) +hot_pink2 = Colors.register( + RGB(215, 95, 175), + HSL(320, 60, 61), + 'HotPink2', + 169, +) +orchid = Colors.register(RGB(215, 95, 215), HSL(300, 60, 61), 'Orchid', 170) +medium_orchid1 = Colors.register( + RGB(215, 95, 255), + HSL(285, 100, 69), + 'MediumOrchid1', + 171, +) +orange3 = Colors.register(RGB(215, 135, 0), HSL(38, 100, 42), 'Orange3', 172) +light_salmon3 = Colors.register( + RGB(215, 135, 95), + HSL(20, 60, 61), + 'LightSalmon3', + 173, +) +light_pink3 = Colors.register( + RGB(215, 135, 135), + HSL(0, 50, 69), + 'LightPink3', + 174, +) +pink3 = Colors.register(RGB(215, 135, 175), HSL(330, 50, 69), 'Pink3', 175) +plum3 = Colors.register(RGB(215, 135, 215), HSL(300, 50, 69), 'Plum3', 176) +violet = Colors.register(RGB(215, 135, 255), HSL(280, 100, 76), 'Violet', 177) +gold3 = Colors.register(RGB(215, 175, 0), HSL(49, 100, 42), 'Gold3', 178) +light_goldenrod3 = Colors.register( + RGB(215, 175, 95), + HSL(40, 60, 61), + 'LightGoldenrod3', + 179, +) +tan = Colors.register(RGB(215, 175, 135), HSL(30, 50, 69), '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, 61), 'Khaki3', 185) +light_goldenrod2 = Colors.register( + RGB(215, 215, 135), + HSL(60, 50, 69), + '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(69, 100, 50), 'Yellow2', 190) +dark_olive_green1 = Colors.register( + RGB(215, 255, 95), + HSL(75, 100, 69), + '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(338, 100, 50), + 'DeepPink2', + 197, +) +deep_pink1 = Colors.register( + RGB(255, 0, 135), + HSL(328, 100, 50), + 'DeepPink1', + 198, +) +deep_pink1 = Colors.register( + RGB(255, 0, 175), + HSL(319, 100, 50), + 'DeepPink1', + 199, +) +magenta2 = Colors.register( + RGB(255, 0, 215), + HSL(309, 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(22, 100, 50), + 'OrangeRed1', + 202, +) +indian_red1 = Colors.register( + RGB(255, 95, 95), + HSL(0, 100, 69), + 'IndianRed1', + 203, +) +indian_red1 = Colors.register( + RGB(255, 95, 135), + HSL(345, 100, 69), + 'IndianRed1', + 204, +) +hot_pink = Colors.register( + RGB(255, 95, 175), + HSL(330, 100, 69), + 'HotPink', + 205, +) +hot_pink = Colors.register( + RGB(255, 95, 215), + HSL(315, 100, 69), + 'HotPink', + 206, +) +medium_orchid1 = Colors.register( + RGB(255, 95, 255), + HSL(300, 100, 69), + 'MediumOrchid1', + 207, +) +dark_orange = Colors.register( + RGB(255, 135, 0), + HSL(32, 100, 50), + 'DarkOrange', + 208, +) +salmon1 = Colors.register(RGB(255, 135, 95), HSL(15, 100, 69), '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(41, 100, 50), 'Orange1', 214) +sandy_brown = Colors.register( + RGB(255, 175, 95), + HSL(30, 100, 69), + '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(51, 100, 50), 'Gold1', 220) +light_goldenrod2 = Colors.register( + RGB(255, 215, 95), + HSL(45, 100, 69), + '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, 69), + '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, 11), 'Grey11', 234) +grey15 = Colors.register(RGB(38, 38, 38), HSL(0, 0, 15), 'Grey15', 235) +grey19 = Colors.register(RGB(48, 48, 48), HSL(0, 0, 19), 'Grey19', 236) +grey23 = Colors.register(RGB(58, 58, 58), HSL(0, 0, 23), 'Grey23', 237) +grey27 = Colors.register(RGB(68, 68, 68), HSL(0, 0, 27), 'Grey27', 238) +grey30 = Colors.register(RGB(78, 78, 78), HSL(0, 0, 31), 'Grey30', 239) +grey35 = Colors.register(RGB(88, 88, 88), HSL(0, 0, 35), 'Grey35', 240) +grey39 = Colors.register(RGB(98, 98, 98), HSL(0, 0, 38), 'Grey39', 241) +grey42 = Colors.register(RGB(108, 108, 108), HSL(0, 0, 42), '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, 62), 'Grey62', 247) +grey66 = Colors.register(RGB(168, 168, 168), HSL(0, 0, 66), 'Grey66', 248) +grey70 = Colors.register(RGB(178, 178, 178), HSL(0, 0, 70), 'Grey70', 249) +grey74 = Colors.register(RGB(188, 188, 188), HSL(0, 0, 74), 'Grey74', 250) +grey78 = Colors.register(RGB(198, 198, 198), HSL(0, 0, 78), 'Grey78', 251) +grey82 = Colors.register(RGB(208, 208, 208), HSL(0, 0, 82), '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..707f0485 --- /dev/null +++ b/progressbar/terminal/stream.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import sys +import typing +from collections.abc import Generator, 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) -> 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 b9fb977e..affb15c1 100644 --- a/progressbar/utils.py +++ b/progressbar/utils.py @@ -1,177 +1,583 @@ +from __future__ import annotations + +import atexit +import contextlib +import datetime +import io +import logging import os -import math - - -def timedelta_to_seconds(delta): - '''Convert a timedelta to seconds with the microseconds as fraction - >>> from datetime import timedelta - >>> '%d' % timedelta_to_seconds(timedelta(days=1)) - '86400' - >>> '%d' % timedelta_to_seconds(timedelta(seconds=1)) - '1' - >>> '%.6f' % timedelta_to_seconds(timedelta(seconds=1, microseconds=1)) - '1.000001' - >>> '%.6f' % timedelta_to_seconds(timedelta(microseconds=1)) - '0.000001' - ''' - # Only convert to float if needed - if delta.microseconds: - total = delta.microseconds * 1e-6 +import re +import sys +import typing +from collections.abc import Callable, Iterable, Iterator, Mapping +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 + +# 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 = typing.TypeVar('StringT', bound=types.StringTypes) +T = typing.TypeVar('T') + +logger: logging.Logger = logging.getLogger(__name__) + + +class _ProgressListener(typing.Protocol): + """Structural type for the bars the stream wrapper notifies. + + Defined locally instead of importing ``ProgressBarMixinBase`` from + ``bar`` so ``utils`` has no dependency on ``bar`` — not even a + type-checking-only one, which CodeQL still reports as a ``bar`` <-> + ``utils`` module-level import cycle. + """ + + def update(self) -> None: + """Redraw in response to redirected output being written.""" + + +# Precompiled ANSI CSI escape-sequence patterns (str and bytes). Compiled once +# at import instead of per no_color() call, which runs for every widget on +# every redraw. +_ANSI_COLOR_RE: re.Pattern[str] = re.compile('\x1b\\[.*?[@-~]') +_ANSI_COLOR_RE_BYTES: re.Pattern[bytes] = re.compile( + bytes(terminal.ESC, 'ascii') + b'\\[.*?[@-~]', +) + + +@typing.overload +def deltas_to_seconds( + *deltas: None | datetime.timedelta | float | int, + default: type[ValueError] = ..., +) -> float: + """Coalesce to seconds; raise ``ValueError`` if no delta is valid.""" + + +@typing.overload +def deltas_to_seconds( + *deltas: None | datetime.timedelta | float | int, + default: T, +) -> float | T: + """Coalesce to seconds; return ``default`` if no delta is valid.""" + + +def deltas_to_seconds( + *deltas: None | datetime.timedelta | float | int, + default: typing.Any = ValueError, +) -> typing.Any: + """ + 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: - total = 0 - total += delta.seconds - total += delta.days * 60 * 60 * 24 - return total - - -def scale_1024(x, n_prefixes): - '''Scale a number down to a suitable size, based on powers of 1024. - - Returns the scaled number and the power of 1024 used. - - Use to format numbers of bytes to KiB, MiB, etc. - - >>> scale_1024(310, 3) - (310.0, 0) - >>> scale_1024(2048, 3) - (2.0, 1) - ''' - power = min(int(math.log(x, 2) / 10), n_prefixes - 1) - scaled = float(x) / (2 ** (10 * power)) - return scaled, power - - -def get_terminal_size(): # pragma: no cover - '''Get the current size of your terminal - - Multiple returns are not always a good idea, but in this case it greatly - simplifies the code so I believe it's justified. It's not the prettiest - function but that's never really possible with cross-platform code. - - Returns: - height, width: Two integers containing height and width - ''' - try: - # This works for Python 3, but not Pypy3. Probably the best method if - # it's supported so let's always try - import shutil - h, w = shutil.get_terminal_size((0, 0)) - if w and h: - return h, w - except: # pragma: no cover - pass - - try: - w = int(os.environ.get('COLUMNS')) - h = int(os.environ.get('LINES')) - if w and h: - return h, w - except: # pragma: no cover - pass - - try: - import blessings - terminal = blessings.Terminal() - h, w = terminal._height_and_width - if w and h: - return h, w - except: # pragma: no cover - pass - - try: - h, w = _get_terminal_size_linux() - if w and h: - return h, w - except: # pragma: no cover - pass - - try: - # Windows detection doesn't always work, let's try anyhow - h, w = _get_terminal_size_windows() - if w and h: - return h, w - except: # pragma: no cover - pass - - try: - # needed for window's python in cygwin's xterm! - h, w = _get_terminal_size_tput() - if w and h: - return h, w - except: # pragma: no cover - pass - - return 80, 25 - - -def _get_terminal_size_windows(): # pragma: no cover - res = None - try: - from ctypes import windll, create_string_buffer - - # stdin handle is -10 - # stdout handle is -11 - # stderr handle is -12 - - h = windll.kernel32.GetStdHandle(-12) - csbi = create_string_buffer(22) - res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi) - except: - return None - - if res: - import struct - (_, _, _, _, _, left, top, right, bottom, _, _) = \ - struct.unpack("hhhhHhhhhhh", csbi.raw) - w = right - left + 1 - h = bottom - top + 1 - return w, h + return default + + +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): + # Fast path: with no ESC byte there is nothing to strip, so the regex + # would return the value unchanged anyway. Skipping it avoids a + # substitution on the common plain-text case, which dominates the + # per-redraw render cost (len_color is called for every widget). + if b'\x1b' not in value: + return value # type: ignore + return _ANSI_COLOR_RE_BYTES.sub(b'', value) # type: ignore + elif isinstance(value, str): + if '\x1b' not in value: + return value # type: ignore + return _ANSI_COLOR_RE.sub('', value) # type: ignore else: - return None - - -def _get_terminal_size_tput(): # pragma: no cover - # get terminal width src: http://stackoverflow.com/questions/263890/ - try: - import subprocess - proc = subprocess.Popen( - ['tput', 'cols'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) - output = proc.communicate(input=None) - w = int(output[0]) - proc = subprocess.Popen( - ['tput', 'lines'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) - output = proc.communicate(input=None) - h = int(output[0]) - return w, h - except: - return None - - -def _get_terminal_size_linux(): # pragma: no cover - def ioctl_GWINSZ(fd): - try: - import fcntl - import termios - import struct - size = struct.unpack( - 'hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234')) - except: - return None - return size - - size = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2) - - if not size: - try: - fd = os.open(os.ctermid(), os.O_RDONLY) - size = ioctl_GWINSZ(fd) - os.close(fd) - except: - pass - if not size: - try: - size = os.environ['LINES'], os.environ['COLUMNS'] - except: - return None - - return int(size[1]), int(size[0]) + 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[_ProgressListener] + needs_clear: bool = False + + def __init__( + self, + target: base.IO, + capturing: bool = False, + listeners: set[_ProgressListener] | None = 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: int | None = 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: Callable[ + [ + type[BaseException], + BaseException, + TracebackType | None, + ], + None, + ] + wrapped_stdout: int = 0 + wrapped_stderr: int = 0 + wrapped_logging: int = 0 + wrapped_excepthook: int = 0 + logging_handlers: list[tuple[logging.StreamHandler[base.IO], base.IO]] + capturing: int = 0 + listeners: set[_ProgressListener] + + 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_logging = 0 + self.wrapped_excepthook = 0 + self.logging_handlers = [] + 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: _ProgressListener | None = None) -> None: + if bar: # pragma: no branch + self.listeners.add(bar) + + self.capturing += 1 + self.update_capturing() + + def stop_capturing(self, bar: _ProgressListener | 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 wrap_logging(self) -> None: + """Retarget stdout/stderr logging handlers to wrapped streams.""" + self.wrapped_logging += 1 + if self.wrapped_logging > 1: + return + + wrapped_streams = { + self.original_stdout: self.stdout, + self.original_stderr: self.stderr, + sys.stdout: self.stdout, + sys.stderr: self.stderr, + } + restore_streams: dict[object, base.IO] = {} + if isinstance(self.stdout, WrappingIO): + restore_streams[self.stdout] = self.original_stdout + if isinstance(self.stderr, WrappingIO): + restore_streams[self.stderr] = self.original_stderr + + seen: set[int] = set() + for logger_ in self._iter_loggers(): + for handler in tuple(logger_.handlers): + if id(handler) in seen: + continue + seen.add(id(handler)) + if not isinstance(handler, logging.StreamHandler): + continue + self._wrap_logging_handler( + handler, + wrapped_streams, + restore_streams, + ) + + def _wrap_logging_handler( + self, + handler: logging.StreamHandler[base.IO], + wrapped_streams: Mapping[types.Any, types.Any], + restore_streams: Mapping[types.Any, base.IO], + ) -> None: + stream = handler.stream + replacement = wrapped_streams.get(stream) + if replacement is not None and replacement is not stream: + if self._set_handler_stream(handler, replacement): + self.logging_handlers.append((handler, stream)) + elif (restore_stream := restore_streams.get(stream)) is not None: + self.logging_handlers.append((handler, restore_stream)) + + def unwrap_logging(self) -> None: + if self.wrapped_logging > 1: + self.wrapped_logging -= 1 + return + if not self.wrapped_logging: + return + + while self.logging_handlers: + handler, stream = self.logging_handlers.pop() + self._set_handler_stream(handler, stream) + self.wrapped_logging = 0 + + def _set_handler_stream( + self, + handler: logging.StreamHandler[base.IO], + stream: types.Any, + ) -> bool: + with contextlib.suppress(AttributeError, ValueError): + handler.setStream(stream) + return True + return False + + def _iter_loggers(self) -> types.Iterator[logging.Logger]: + yield logging.getLogger() + for logger_ in tuple(logging.Logger.manager.loggerDict.values()): + if isinstance(logger_, logging.Logger): + yield logger_ + + def unwrap_excepthook(self) -> None: + if self.wrapped_excepthook: + self.wrapped_excepthook -= 1 + 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: 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) -> typing.Any: + if name in self: + return self[name] + else: + raise AttributeError(f'No such attribute: {name}') + + def __setattr__(self, name: str, value: typing.Any) -> 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}') + + +streams = StreamWrapper() +atexit.register(streams.flush) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 4a3b46c7..73d33e7c 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1,18 +1,137 @@ -from __future__ import division, absolute_import, with_statement -from __future__ import print_function +from __future__ import annotations -import datetime import abc -import sys -import pprint +import collections.abc +import contextlib +import datetime +import functools +import logging +import typing + +# Ruff is being stupid and doesn't understand `ClassVar` if it comes from the +# `types` module +from typing import ClassVar + +from python_utils import containers, converters + +from . import algorithms, base, terminal, utils +from .terminal import colors + +if typing.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 = dict[str, typing.Any] +FormatString = str | None + +T = typing.TypeVar('T') + + +def string_or_lambda( + input_: str | collections.abc.Callable[..., str], +) -> collections.abc.Callable[..., str]: + if isinstance(input_, str): + + def render_input(progress, data, width): + return input_ % data + + return render_input + else: + return input_ + + +def create_wrapper( + wrapper: str | tuple[str | None, str | None] | None, +) -> str | None: + """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): + if '{}' not in wrapper: + raise ValueError('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: str | collections.abc.Callable[..., str], + wrap: str | tuple[str | None, str | None] | None = None, +) -> collections.abc.Callable[..., str]: + if isinstance(marker, str): + # Narrow to ``str`` once, in a fresh local, so the ``_marker`` closure + # below closes over a plain ``str`` (no cast needed). ``_marker`` is + # only ever wrapped in this branch. + marker_str = converters.to_unicode(marker) + if utils.len_color(marker_str) != 1: + raise ValueError('Markers are required to be 1 char') + + 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_str * length + else: + return marker_str -from . import utils -from . import six -from . import base + return wrapper(_marker, wrap) + else: + return wrapper(marker, wrap) -class FormatWidgetMixin(object): - '''Mixin to format widgets using a formatstring +class FormatWidgetMixin: + """Mixin to format widgets using a formatstring. Variables available: - max_value: The maximum value (can be None with iterators) @@ -25,275 +144,662 @@ class FormatWidgetMixin(object): - time_elapsed: Shortcut for HH:MM:SS time since the bar started including days - percentage: Percentage as a float - ''' - required_values = [] + """ - def __init__(self, format, **kwargs): + def __init__( + self, format: str, new_style: bool = False, **kwargs: typing.Any + ): + self.new_style = new_style self.format = format - - def __call__(self, progress, data, format=None): - '''Formats the widget into a string''' + # Cooperative: forward remaining kwargs to the next base. ``format`` is + # consumed here and deliberately not forwarded onward. + super().__init__(**kwargs) + + def get_format( + self, + progress: ProgressBarMixinBase, + data: Data, + format: str | None = None, + ) -> str: + return format or self.format + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: str | None = None, + ) -> str: + """Formats the widget into a string.""" + format_ = self.get_format(progress, data, format) try: - return (format or self.format) % data + if self.new_style: + return format_.format(**data) + else: + return format_ % data except (TypeError, KeyError): - print('Error while formatting %r' % self.format, file=sys.stderr) - pprint.pprint(data, stream=sys.stderr) + logger.exception( + 'Error while formatting %r with data: %r', + format_, + data, + ) raise -class WidthWidgetMixin(object): - '''Mixing to make sure widgets are only visible if the screen is within a +class _WidgetKwargsSink: + """Terminates cooperative ``__init__`` chains for widgets. + + Absorbs keyword arguments no widget class consumed so a cooperative + chain never reaches ``object.__init__`` with leftovers. Tolerated + silently for backwards compatibility: third-party widgets have passed + stray kwargs to their parents for years. + """ + + def __init__(self, **kwargs: typing.Any) -> None: + super().__init__() + + +class WidthWidgetMixin(_WidgetKwargsSink): + """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.. - ''' - def __init__(self, min_width=None, max_width=None, **kwargs): + 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 + super().__init__(**kwargs) - def check_size(self, progress): - if self.min_width and self.min_width > progress.term_width: + def check_size(self, progress: ProgressBarMixinBase): + max_width = self.max_width + min_width = self.min_width + if min_width and min_width > progress.term_width: return False - elif self.max_width and self.max_width < progress.term_width: + elif max_width and max_width < progress.term_width: # noqa: SIM103 return False else: return True -class WidgetBase(object): - __metaclass__ = abc.ABCMeta - '''The base class for all widgets +class TGradientColors(typing.TypedDict): + fg: terminal.OptionalColor + bg: terminal.OptionalColor + + +class TFixedColors(typing.TypedDict): + fg_none: terminal.Color | None + bg_none: 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 - def __init__(self, **kwargs): - pass + Variables available: + - min_width: Only display the widget if at least `min_width` is left + - max_width: Only display the widget if at most `max_width` is left + - weight: Widgets with a higher `weight` will be calculated before widgets + with a lower one + - copy: Copy this widget when initializing the progress bar so the + progressbar can be reused. Some widgets such as the FormatCustomText + require the shared state so this needs to be optional + + """ + + copy = True @abc.abstractmethod - def __call__(self, progress, data): - '''Updates the widget. + def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: + """Updates the widget. progress - a reference to the calling ProgressBar - ''' - + """ + + # Class-level defaults; instances may hold their own copy when a + # ``fixed_colors``/``gradient_colors`` override is passed (copy-on-write in + # ``__init__``), so these are not ``ClassVar``. + _fixed_colors: TFixedColors = TFixedColors( + fg_none=None, + bg_none=None, + ) + _gradient_colors: TGradientColors = TGradientColors( + fg=None, + bg=None, + ) + _len: collections.abc.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 -class AutoWidthWidgetBase(WidgetBase): - '''The base class for all variable width widgets. + def __init__( + self, + *args, + fixed_colors=None, + gradient_colors=None, + **kwargs, + ): + # Copy-on-write: merge overrides into a fresh per-instance dict so we + # never mutate the shared class-level mapping (which is inherited by + # every other instance and subclass). + if fixed_colors is not None: + merged_fixed = type(self)._fixed_colors.copy() + merged_fixed.update(fixed_colors) + self._fixed_colors = merged_fixed + + if gradient_colors is not None: + merged_gradient = type(self)._gradient_colors.copy() + merged_gradient.update(gradient_colors) + self._gradient_colors = merged_gradient + + # Drop any cached ``uses_colors``: old-style code that calls parents + # with different kwargs per pass would otherwise keep a stale + # ``uses_colors=False`` from the first pass (before ``fixed_colors``/ + # ``gradient_colors`` were applied) and silently lose color rendering. + vars(self).pop('uses_colors', None) + + 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) - + """ -def _format_time(seconds): - '''Formats time as the string "HH:MM:SS".''' - return str(datetime.timedelta(seconds=int(seconds))) + INTERVAL = datetime.timedelta(milliseconds=100) -class FormatLabel(FormatWidgetMixin, WidthWidgetMixin): - '''Displays a formatted label +class FormatLabel(FormatWidgetMixin, WidgetBase): + """Displays a formatted label. >>> label = FormatLabel('%(value)s', min_width=5, max_width=10) - >>> class Progress(object): + >>> class Progress: ... pass + >>> label = FormatLabel('{value} :: {value:^6}', new_style=True) + >>> str(label(Progress, dict(value='test'))) + 'test :: test ' + + """ + + mapping: ClassVar[dict[str, tuple[str, typing.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): + super().__init__(format=format, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: str | None = None, + ): + for name, (key, transform) in self.mapping.items(): + # Avoid a per-entry contextlib.suppress on the redraw hot path: a + # missing key is the only common "miss", so test membership + # directly and only guard the transform (which can raise on bad + # values) with try/except. + if key not in data: + continue + if transform is None: + data[name] = data[key] + else: + with contextlib.suppress(ValueError, IndexError): + data[name] = transform(data[key]) - >>> Progress.term_width = 0 - >>> label(Progress, dict(value='test')) - '' + return FormatWidgetMixin.__call__(self, progress, data, format) - >>> Progress.term_width = 5 - >>> label(Progress, dict(value='test')) - 'test' - >>> Progress.term_width = 10 - >>> label(Progress, dict(value='test')) - 'test' +class Timer(FormatLabel, TimeSensitiveWidgetBase): + """WidgetBase which displays the elapsed seconds.""" - >>> Progress.term_width = 11 - >>> label(Progress, dict(value='test')) - '' - ''' - - mapping = { - 'finished': ('end_time', None), - 'last_update': ('last_update_time', None), - 'max': ('max_value', None), - 'seconds': ('seconds_elapsed', None), - 'start': ('start_time', None), - 'elapsed': ('total_seconds_elapsed', _format_time), - 'value': ('value', None), - } - - def __init__(self, format, **kwargs): - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidthWidgetMixin.__init__(self, **kwargs) - - def __call__(self, progress, data): - if not self.check_size(progress): - return '' + def __init__( + self, format='Elapsed Time: %(elapsed)s', **kwargs: typing.Any + ): + # Backwards compatibility: very old configs used a bare ``%s`` + # placeholder for the elapsed time. Silently rewrite it to the named + # ``%(elapsed)s`` form the widget actually formats with. + if '%s' in format and '%(elapsed)s' not in format: + format = format.replace('%s', '%(elapsed)s') - 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 + super().__init__(format=format, **kwargs) - return FormatWidgetMixin.__call__(self, progress, data) + # This is exposed as a static method for backwards compatibility + format_time = staticmethod(utils.format_time) -class Timer(FormatLabel, TimeSensitiveWidgetBase): - '''WidgetBase which displays the elapsed seconds.''' +class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta): + """ + Mixing for widgets that average multiple measurements. - def __init__(self, format='Elapsed Time: %(elapsed)s', **kwargs): - FormatLabel.__init__(self, format=format, **kwargs) - TimeSensitiveWidgetBase.__init__(self, **kwargs) + Note that samples can be either an integer or a timedelta to indicate a + certain amount of time - # This is exposed as a static method for backwards compatibility - format_time = staticmethod(_format_time) + >>> class progress: + ... last_update_time = datetime.datetime.now() + ... value = 1 + ... extra = dict() + >>> samples = SamplesMixin(samples=2) + >>> samples(progress, None, True) + (None, None) + >>> progress.last_update_time += datetime.timedelta(seconds=1) + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True -class SamplesMixin(object): - def __init__(self, samples=10, key_prefix=None, **kwargs): - self.samples = samples - self.key_prefix = (self.__class__.__name__ or key_prefix) + '_' + >>> progress.last_update_time += datetime.timedelta(seconds=1) + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True - def get_sample_times(self, progress, data): - return progress.extra.setdefault(self.key_prefix + 'sample_times', []) + >>> samples = SamplesMixin(samples=datetime.timedelta(seconds=1)) + >>> _, value = samples(progress, None) + >>> value + SliceableDeque([1, 1]) - def get_sample_values(self, progress, data): - return progress.extra.setdefault(self.key_prefix + 'sample_values', []) + >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) + True + """ - def __call__(self, progress, data): + def __init__( + self, + samples: datetime.timedelta | int = datetime.timedelta(seconds=2), + key_prefix=None, + **kwargs, + ): + self.samples = samples + self.key_prefix = (key_prefix or self.__class__.__name__) + '_' + super().__init__(**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, + ): + # Backwards compatibility: rewrite a legacy bare ``%s`` placeholder to + # the named ``%(eta)s`` form (see ``Timer.__init__`` for the same + # elapsed-time shim). + if '%s' in format and '%(eta)s' not in format: + format = format.replace('%s', '%(eta)s') + + # ``super().__init__`` (Timer) sets ``self.format`` to the + # elapsed-time default; the ETA-specific ``self.format*`` assignments + # below MUST stay after it or ETA renders 'Elapsed Time:' not 'ETA:'. + super().__init__(**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: float, + elapsed: datetime.timedelta | None, + ) -> float: + """Updates the widget to show the ETA or total time when finished.""" + if elapsed: + # The max() prevents zero division errors. ``value`` is always a + # number here (``_resolve_value_elapsed`` fills the default). + per_item = elapsed.total_seconds() / max(value, 1e-6) + remaining = progress.max_value - data['value'] + return remaining * per_item + else: + return 0 + + def _resolve_value_elapsed( + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, + ): + """Fill in the value/elapsed defaults shared by the ETA variants. + + When a caller does not supply them, the per-item rate is based on the + progress relative to ``min_value`` (not the raw value) and the elapsed + time is taken from the data snapshot. + """ + if value is None: + value = data['value'] - progress.min_value + + if elapsed is None: + elapsed = data['time_elapsed'] + + return value, elapsed + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + """Updates the widget to show the ETA or total time when finished.""" + value, elapsed = self._resolve_value_elapsed( + progress, data, value, elapsed + ) + + # ``max_value`` is ``UnknownLength`` (or ``None``) for indeterminate + # bars. The remaining-count subtraction in ``_calculate_eta`` only + # runs (and only then fails) once ``elapsed`` is truthy, so guard on + # both to keep the ``elapsed == 0`` case rendering ``format_zero`` as + # before. Nothing else in the ETA math raises ``TypeError``, so the + # previous try/except-as-control-flow is intentionally removed. + eta_na = False + if elapsed and ( + progress.max_value is None + or progress.max_value is base.UnknownLength + ): + data['eta_seconds'] = None + eta_na = True + else: + data['eta_seconds'] = self._calculate_eta( + progress, + data, + value=value, + elapsed=elapsed, + ) + + 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' % self.format_time( - data['total_seconds_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 \ - - data['total_seconds_elapsed'] - return 'ETA: %s' % self.format_time(eta) + fmt = self.format_zero - 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']) + return Timer.__call__(self, progress, data, format=fmt) -class AbsoluteETA(Timer): - '''Widget which attempts to estimate the absolute time of arrival.''' - - def _eta(self, progress, data, value, elapsed): - """Update the widget to show the ETA or total time when finished.""" - if value == progress.min_value: # pragma: no cover - return 'Estimated finish time: ----/--/-- --:--:--' - elif progress.end_time: - return 'Finished at: %s' % self._format(progress.end_time) - else: - eta = elapsed * progress.max_value / value - elapsed - now = datetime.datetime.now() - eta_abs = now + datetime.timedelta(seconds=eta) - return 'Estimated finish time: %s' % self._format(eta_abs) +class AbsoluteETA(ETA): + """Widget which attempts to estimate the absolute time of arrival.""" - def _format(self, t): - return t.strftime("%Y/%m/%d %H:%M:%S") + def _calculate_eta( + self, + progress: ProgressBarMixinBase, + data: Data, + value: float, + elapsed: datetime.timedelta | None, + ) -> datetime.datetime: + 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, + ): + super().__init__( + 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. - ''' + Very convenient for resuming the progress halfway. For an estimate based + on an exponential moving average (EMA) of the speed instead of a windowed + sample, use `SmoothingETA`. + """ - def __init__(self, **kwargs): - ETA.__init__(self, **kwargs) - SamplesMixin.__init__(self, **kwargs) + exponential_smoothing: bool + exponential_smoothing_factor: float - def __call__(self, progress, data): - times, values = SamplesMixin.__call__(self, progress, data) + def __init__( + self, + exponential_smoothing=True, + exponential_smoothing_factor=0.1, + **kwargs, + ): + self.exponential_smoothing = exponential_smoothing + self.exponential_smoothing_factor = exponential_smoothing_factor + super().__init__(**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 - if len(times) <= 1: - # No samples so just return the normal ETA calculation - return ETA.__call__(self, progress, data) - else: - return self._eta(progress, data, values[-1] - values[0], - utils.timedelta_to_seconds(times[-1] - times[0])) + return ETA.__call__(self, progress, data, value=value, elapsed=elapsed) -class DataSize(FormatWidgetMixin): - ''' +class SmoothingETA(ETA): + """ + WidgetBase which attempts to estimate the time of arrival using an + exponential moving average (EMA) of the speed. + + EMA applies more weight to recent data points and less to older ones, + and doesn't require storing all past values. This approach works well + with varying data points and smooths out fluctuations effectively. + """ + + smoothing_algorithm: algorithms.SmoothingAlgorithm + smoothing_parameters: dict[str, float] + + def __init__( + self, + smoothing_algorithm: type[ + algorithms.SmoothingAlgorithm + ] = algorithms.ExponentialMovingAverage, + smoothing_parameters: dict[str, float] | None = None, + **kwargs, + ): + self.smoothing_parameters = smoothing_parameters or {} + self.smoothing_algorithm = smoothing_algorithm( + **self.smoothing_parameters, + ) + super().__init__(**kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + value=None, + elapsed=None, + ): + value, elapsed = self._resolve_value_elapsed( + progress, data, value, 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='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) - - def __call__(self, progress, data): + super().__init__(format=format, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: str | None = None, + ): value = data[self.variable] if value is not None: scaled, power = utils.scale_1024(value, len(self.prefixes)) @@ -304,48 +810,70 @@ def __call__(self, progress, data): data['prefix'] = self.prefixes[power] data['unit'] = self.unit - return FormatWidgetMixin.__call__(self, progress, data) + return FormatWidgetMixin.__call__(self, progress, data, format) class FileTransferSpeed(FormatWidgetMixin, TimeSensitiveWidgetBase): - ''' - WidgetBase for showing the transfer speed (useful for file transfers). - ''' + """ + Widget for showing the current transfer speed (useful for file transfers). + """ def __init__( - self, format='%(scaled)5.1f %(prefix)s%(unit)-s/s', - inverse_format='%(scaled)5.1f s/%(prefix)s%(unit)-s', unit='B', - prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), - **kwargs): + self, + format='%(scaled)5.1f %(prefix)s%(unit)-s/s', + inverse_format='%(scaled)5.1f s/%(prefix)s%(unit)-s', + unit='B', + prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), + **kwargs, + ): self.unit = unit self.prefixes = prefixes self.inverse_format = inverse_format - FormatWidgetMixin.__init__(self, format=format, **kwargs) - TimeSensitiveWidgetBase.__init__(self, **kwargs) + super().__init__(format=format, **kwargs) def _speed(self, value, elapsed): speed = float(value) / elapsed return utils.scale_1024(speed, len(self.prefixes)) - def __call__(self, progress, data, value=None, total_seconds_elapsed=None): - '''Updates the widget with the current SI prefixed speed.''' - value = data['value'] or value - elapsed = data['total_seconds_elapsed'] or total_seconds_elapsed - - if value is not None and elapsed is not None \ - and elapsed > 2e-6 and value > 2e-6: # =~ 0 + 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['unit'] = self.unit - if power == 0 and scaled < 0.1: - if scaled > 0: - scaled = 1 / scaled - data['scaled'] = scaled + 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) + return FormatWidgetMixin.__call__( + self, + progress, + data, + self.inverse_format, + ) else: data['scaled'] = scaled data['prefix'] = self.prefixes[power] @@ -353,81 +881,245 @@ def __call__(self, progress, data, value=None, total_seconds_elapsed=None): class AdaptiveTransferSpeed(FileTransferSpeed, SamplesMixin): - '''WidgetBase for showing the transfer speed, based on the last X samples - ''' - - def __init__(self, **kwargs): - FileTransferSpeed.__init__(self, **kwargs) - SamplesMixin.__init__(self, **kwargs) - - def __call__(self, progress, data): - times, values = SamplesMixin.__call__(self, progress, data) - if len(times) <= 1: - # No samples so just return the normal transfer speed calculation - value = None - elapsed = None - else: - value = values[-1] - values[0] - elapsed = utils.timedelta_to_seconds(times[-1] - times[0]) - + """Widget for showing the transfer speed based on the last X samples.""" + + def __init__(self, **kwargs: typing.Any): + super().__init__(**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, **kwargs): + def __init__( + self, + markers: str = '|/-\\', + default: str | None = None, + fill: str = '', + marker_wrap: str | tuple[str | None, str | None] | None = None, + fill_wrap: str | tuple[str | None, str | None] | None = None, + **kwargs: typing.Any, + ): self.markers = markers + self.marker_wrap = create_wrapper(marker_wrap) self.default = default or markers[0] - WidgetBase.__init__(self, **kwargs) - - def __call__(self, progress, data, width=None): - '''Updates the widget to show the next marker or the first marker when - finished''' - + self.fill_wrap = create_wrapper(fill_wrap) + self.fill = create_marker(fill, self.fill_wrap) if fill else None + super().__init__(**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 = '' -# Alias for backwards compatibility + # 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 + + +# Legacy alias for `AnimatedMarker`, kept for backwards compatibility. Kept as +# a plain alias (no DeprecationWarning) until the next major version. RotatingMarker = AnimatedMarker class Counter(FormatWidgetMixin, WidgetBase): - '''Displays the current count''' + """Displays the current count.""" - def __init__(self, format='%(value)d', **kwargs): - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidgetBase.__init__(self, format=format, **kwargs) + def __init__(self, format='%(value)d', **kwargs: typing.Any): + # ``format`` is consumed by ``FormatWidgetMixin``; do not leak it into + # the ``WidgetBase`` tail of the cooperative chain. + super().__init__(format=format, **kwargs) + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, + ): + return FormatWidgetMixin.__call__(self, progress, data, format) -class Percentage(FormatWidgetMixin, WidgetBase): - '''Displays the current percentage as a number with a percent sign.''' - def __init__(self, format='%(percentage)3d%%', **kwargs): - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidgetBase.__init__(self, format=format, **kwargs) +class ColoredMixin: + # See ``WidgetBase``: class-level defaults, overridable per instance. + _fixed_colors: TFixedColors = TFixedColors( + fg_none=colors.yellow, + bg_none=None, + ) + _gradient_colors: TGradientColors = TGradientColors( + fg=colors.gradient, + bg=None, + ) -class SimpleProgress(FormatWidgetMixin, WidgetBase): - '''Returns progress as a count of the total (e.g.: "5 of 47")''' +class Percentage(FormatWidgetMixin, ColoredMixin, WidgetBase): + """Displays the current percentage as a number with a percent sign.""" - def __init__(self, format='%(value)d of %(max_value)d', max_width=None, - **kwargs): - self.max_width = dict(default=max_width) - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidgetBase.__init__(self, format=format, **kwargs) + def __init__( + self, format='%(percentage)3d%%', na='N/A%%', **kwargs: typing.Any + ): + self.na = na + super().__init__(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) + + +UNIT_PREFIXES = ('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi') +DEFAULT_UNIT = object() + + +def format_unit_value(value, unit='it', unit_scale=False) -> str: + if value in (None, base.UnknownLength): + return 'N/A' + if unit_scale: + scaled, power = utils.scale_1024(float(value), len(UNIT_PREFIXES)) + prefix = UNIT_PREFIXES[int(power)] + return f'{scaled:.1f} {prefix}{unit}' + if isinstance(value, float): + return f'{value:g} {unit}' + return f'{value} {unit}' - def __call__(self, progress, data, format=None): - formatted = FormatWidgetMixin.__call__(self, progress, data, - format=format) + +class UnitProgress(WidgetBase): + """Displays progress as a count with an optional unit and 1024 scaling.""" + + def __init__( + self, + unit=DEFAULT_UNIT, + unit_scale=DEFAULT_UNIT, + **kwargs: typing.Any, + ): + self.use_progress_unit = unit is DEFAULT_UNIT + self.use_progress_unit_scale = unit_scale is DEFAULT_UNIT + self.unit: str = ( + 'it' if unit is DEFAULT_UNIT else typing.cast(str, unit) + ) + self.unit_scale: bool = ( + False + if unit_scale is DEFAULT_UNIT + else typing.cast(bool, unit_scale) + ) + WidgetBase.__init__(self, **kwargs) + + def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: + unit = typing.cast(str, data.get('unit', self.unit)) + unit_scale = typing.cast(bool, data.get('unit_scale', self.unit_scale)) + if not self.use_progress_unit: + unit = self.unit + if not self.use_progress_unit_scale: + unit_scale = self.unit_scale + value = format_unit_value(data.get('value'), unit, unit_scale) + max_value = format_unit_value(data.get('max_value'), unit, unit_scale) + return f'{value} of {max_value}' + + +class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase): + """Returns progress as a count of the total (e.g.: "5 of 47").""" + + max_width_cache: dict[ + str + | tuple[ + NumberT | type[base.UnknownLength] | None, + NumberT | type[base.UnknownLength] | None, + ], + int | None, + ] + + DEFAULT_FORMAT = '%(value_s)s of %(max_value_s)s' + + def __init__(self, format=DEFAULT_FORMAT, **kwargs: typing.Any): + super().__init__(format=format, **kwargs) + # ``max_width_cache`` reads ``self.max_width``; keep it after super(). + 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' + + # 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 = self.max_width.get(key, self.max_width['default']) + max_width: int | None = self.max_width_cache.get( + key, + self.max_width, + ) if not max_width: temporary_data = data.copy() for value in key: @@ -435,25 +1127,42 @@ def __call__(self, progress, data, format=None): continue temporary_data['value'] = value - width = len(FormatWidgetMixin.__call__( - self, progress, temporary_data, format=format)) - if width: # pragma: no branch + if width := progress.custom_len( # pragma: no branch + FormatWidgetMixin.__call__( + self, + progress, + temporary_data, + format=format, + ), + ): max_width = max(max_width or 0, width) - self.max_width[key] = max_width + self.max_width_cache[key] = max_width # Adjust the output to have a consistent size in all cases if max_width: # pragma: no branch formatted = formatted.rjust(max_width) - return formatted + + return self._apply_colors(formatted, data) class Bar(AutoWidthWidgetBase): + """A progress bar which stretches to fill the line.""" + + fg: terminal.OptionalColor = colors.gradient + bg: terminal.OptionalColor = None - '''A progress bar which stretches to fill the line.''' - def __init__(self, marker='#', left='|', right='|', fill=' ', - fill_left=True, **kwargs): - '''Creates a customizable progress bar. + def __init__( + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=True, + marker_wrap=None, + **kwargs, + ): + """Creates a customizable progress bar. The callable takes the same parameters as the `__call__` method @@ -462,94 +1171,675 @@ def __init__(self, marker='#', left='|', right='|', fill=' ', right - string or callable object to use as a right border fill - character to use for the empty part of the progress bar fill_left - whether to fill from the left or the right - ''' - def string_or_lambda(input_): - if isinstance(input_, six.basestring): - def render_input(progress, data, width): - return input_ % data - - return render_input - else: - return input_ - - def _marker(marker): - def __marker(progress, data, width): - if progress.max_value is not base.UnknownLength \ - and progress.max_value > 0: - length = int(progress.value / progress.max_value * width) - return (marker * length) - else: - return '' - - if isinstance(marker, six.basestring): - assert len(marker) == 1, 'Markers are required to be 1 char' - return __marker - else: - return marker - - self.marker = _marker(marker) + """ + self.marker = create_marker(marker, marker_wrap) self.left = string_or_lambda(left) self.right = string_or_lambda(right) self.fill = string_or_lambda(fill) self.fill_left = fill_left - AutoWidthWidgetBase.__init__(self, **kwargs) - - def __call__(self, progress, data, width): - '''Updates the progress bar and its subcomponents''' - - 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) + super().__init__(**kwargs) + + def _render_borders( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int, + ) -> tuple[str, str, int]: + """Resolve the left/right borders and the width left for the body. + + The borders may be callables, so they are resolved against + ``progress``/``data`` and their visible length subtracted from + ``width``. Shared by every :class:`Bar` subclass' ``__call__``. + """ + 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) + return left, right, width + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color: bool = True, + ): + """Updates the progress bar and its subcomponents.""" + left, right, width = self._render_borders(progress, data, width) + marker = converters.to_unicode(self.marker(progress, data, width)) + fill = converters.to_unicode(self.fill(progress, data, width)) + + # Make sure we ignore invisible characters when filling + width += len(marker) - progress.custom_len(marker) if self.fill_left: marker = marker.ljust(width, fill) else: marker = marker.rjust(width, fill) + if color: + marker = self._apply_colors(marker, data) + return left + marker + right class ReverseBar(Bar): + """A bar which has a marker that goes from right to left.""" - '''A bar which has a marker which bounces from side to side.''' - - def __init__(self, marker='#', left='|', right='|', fill=' ', - fill_left=False, **kwargs): - '''Creates a customizable progress bar. + def __init__( + self, + marker='#', + left='|', + right='|', + fill=' ', + fill_left=False, + **kwargs, + ): + """Creates a customizable progress bar. marker - string or updatable object to use as a marker left - string or updatable object to use as a left border right - string or updatable object to use as a right border fill - character to use for the empty part of the progress bar fill_left - whether to fill from the left or the right - ''' - Bar.__init__(self, marker=marker, left=left, right=right, fill=fill, - fill_left=fill_left, **kwargs) + """ + super().__init__( + 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: bool = True, + ): + """Updates the progress bar and its subcomponents.""" + left, right, width = self._render_borders(progress, data, width) + 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 BouncingBar(Bar): +class FormatCustomText(FormatWidgetMixin, WidgetBase): + mapping: dict[str, typing.Any] = dict() # noqa: RUF012 + copy = False + + def __init__( + self, + format: str, + mapping: dict[str, typing.Any] | None = None, + **kwargs, + ): + # ``self.format`` is set by FormatWidgetMixin.__init__ (via super + # below); assigning it here too would just overwrite that. + # Always build a fresh per-instance dict so update_mapping() never + # mutates the shared class-level default (which every other + # default-constructed instance would otherwise alias). Fall back to + # the class-level `mapping` so subclasses that declare defaults keep + # them when no mapping is passed. + self.mapping = dict(self.mapping if mapping is None else mapping) + super().__init__(format=format, **kwargs) + + def update_mapping(self, **mapping: typing.Any): + self.mapping.update(mapping) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: str | None = None, + ): + return FormatWidgetMixin.__call__( + self, + progress, + self.mapping, + format or self.format, + ) + + +class VariableMixin(_WidgetKwargsSink): + """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 + super().__init__(**kwargs) + + +class Postfix(VariableMixin, WidgetBase): + """Displays a live postfix string or key-value mapping.""" + + def __init__( + self, + name='postfix', + prefix=' ', + separator=', ', + **kwargs: typing.Any, + ): + self.prefix = prefix + self.separator = separator + VariableMixin.__init__(self, name=name) + WidgetBase.__init__(self, **kwargs) + + def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: + value = data['variables'].get(self.name) + if value is None or ( + isinstance(value, (str, dict, list, set, tuple)) and not value + ): + return '' + if isinstance(value, str): + rendered = value + elif isinstance(value, dict): + rendered = self.separator.join( + f'{key}={value[key]}' for key in sorted(value) + ) + else: + rendered = str(value) + return f'{self.prefix}{rendered}' + + +class MultiRangeBar(Bar, VariableMixin): + """ + A bar with multiple sub-ranges, each represented by a different symbol. + + 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): + # ``name`` rides through Bar's cooperative chain to VariableMixin. + super().__init__(name=name, **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: bool = True, + ): + """Updates the progress bar and its subcomponents.""" + left, right, width = self._render_borders(progress, data, width) + 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, strict=False): + marker = converters.to_unicode(marker(progress, data, width)) + if progress.custom_len(marker) != 1: + raise ValueError('Markers are required to be 1 char') + + 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)) + if progress.custom_len(fill) != 1: + raise ValueError( + f'Fill is required to be 1 char, got {fill!r}' + ) + middle = fill * width + + return left + middle + right + + +class MultiProgressBar(MultiRangeBar): + def __init__( + self, + name, + # NOTE: the markers are not whitespace even though some + # terminals don't show the characters correctly! + markers=' ▁▂▃▄▅▆▇█', + **kwargs, + ): + super().__init__( + 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: + value = 0.0 - def update(self, progress, width): # pragma: no cover - '''Updates the progress bar and its subcomponents''' + if not 0 <= value <= 1: + raise ValueError( + 'Range value needs to be in the range [0..1], ' + f'got {value}', + ) - left, marker, right = (i for i in (self.left, self.marker, self.right)) + range_ = value * (len(ranges) - 1) + pos = int(range_) + frac = range_ % 1 + ranges[pos] += 1 - frac + if frac: + ranges[pos + 1] += frac - width -= len(left) + len(right) + if self.fill_left: # pragma: no branch + ranges = list(reversed(ranges)) - if progress.finished: - return '%s%s%s' % (left, width * marker, right) + return ranges - 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)) - # Swap if we want to bounce the other way - if not self.fill_left: - rpad, lpad = lpad, rpad +class GranularMarkers: + smooth = ' ▏▎▍▌▋▊▉█' + bar = ' ▁▂▃▄▅▆▇█' + snake = ' ▖▌▛█' + fade_in = ' ░▒▓█' + dots = ' ⡀⡄⡆⡇⣇⣧⣷⣿' + growing_circles = ' .oO' - return '%s%s%s%s%s' % (left, lpad, marker, rpad, right) + +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) + + super().__init__(**kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + ): + # GranularBar descends from AutoWidthWidgetBase, not Bar, so it can't + # reach Bar._render_borders. The border preamble is intentionally + # duplicated here rather than hoisting the helper onto a shared base + # (which would put border concerns on width widgets that have none). + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + + max_value = progress.max_value + if ( + max_value is not base.UnknownLength + and typing.cast(float, max_value) > 0 + ): + percent = progress.value / max_value # type: ignore + else: + percent = 0 + + num_chars = percent * width + + marker = self.markers[-1] * int(num_chars) + + if marker_idx := int((num_chars % 1) * (len(self.markers) - 1)): + marker += self.markers[marker_idx] + + marker = converters.to_unicode(marker) + + # Make sure we ignore invisible characters when filling + width += len(marker) - progress.custom_len(marker) + marker = marker.ljust(width, self.markers[0]) + + return left + marker + right + + +class FormatLabelBar(FormatLabel, Bar): + """A bar which has a formatted label in the center.""" + + def __init__(self, format, **kwargs: typing.Any): + super().__init__(format=format, **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 + ): + super().__init__(format=format, na=na, **kwargs) + + def __call__( # type: ignore + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + format: FormatString = None, + ): + return super().__call__(progress, data, width, format=format) + + +class Variable(FormatWidgetMixin, VariableMixin, WidgetBase): + """Displays a custom variable.""" + + def __init__( + self, + name, + format='{name}: {formatted_value}', + width=6, + precision=3, + **kwargs, + ): + """Creates a Variable associated with the given name.""" + self.width = width + self.precision = precision + # FormatWidgetMixin (first in the MRO) now sets ``self.format``; + # ``name`` rides the cooperative chain to VariableMixin. + super().__init__(name=name, format=format, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: str | None = None, + ): + value = data['variables'][self.name] + context = data.copy() + context['value'] = value + context['name'] = self.name + context['width'] = self.width + context['precision'] = self.precision + + try: + # Make sure to try and cast the value first, otherwise the + # formatting will generate warnings/errors on newer Python releases + value = float(value) + fmt = '{value:{width}.{precision}}' + context['formatted_value'] = fmt.format(**context) + except (TypeError, ValueError): + if value: + context['formatted_value'] = '{value:{width}}'.format( + **context, + ) + else: + context['formatted_value'] = '-' * self.width + + return self.format.format(**context) + + +class DynamicMessage(Variable): + """Legacy alias for `Variable`; prefer `Variable` in new code. + + Kept as a plain subclass (no DeprecationWarning) until the next major + version. + """ + + +class CurrentTime(FormatWidgetMixin, TimeSensitiveWidgetBase): + """Widget which displays the current (date)time with seconds resolution.""" + + INTERVAL = datetime.timedelta(seconds=1) + + def __init__( + self, + format='Current Time: %(current_time)s', + microseconds=False, + **kwargs, + ): + self.microseconds = microseconds + super().__init__(format=format, **kwargs) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + format: str | None = 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] + """Retained for backwards compatibility only. + + Per-run marker state now lives in ``progress.extra`` (see + :py:meth:`get_job_markers`) so a single widget shared by multiple bars no + longer interleaves their markers. This attribute is no longer read or + updated during rendering. + """ + + 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, + ): + # Retained for backward compatibility only; render state now lives in + # ``progress.extra`` (see get_job_markers) so a single widget reused by + # multiple bars no longer interleaves their markers. + self.job_markers = [] + # Unique per-widget key so multiple JobStatusBars on the same bar do + # not share storage either. + self._markers_key = f'{type(self).__name__}_{id(self)}_job_markers' + 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 + + # ``name`` rides Bar's cooperative chain to VariableMixin (which also + # validates it); Bar re-sets left/right/fill from the same values. + super().__init__( + name=name, + left=left, + right=right, + fill=fill, + fill_left=fill_left, + **kwargs, + ) + + def get_job_markers(self, progress: ProgressBarMixinBase) -> list[str]: + # Per-bar marker history, following SamplesMixin's ``progress.extra`` + # pattern so the widget itself stays stateless and reusable. + return progress.extra.setdefault(self._markers_key, []) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color: bool = True, + ): + left, right, width = self._render_borders(progress, data, width) + + 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) + + job_markers = self.get_job_markers(progress) + job_markers.append(marker) + # Drop the oldest markers when they no longer fit the + # available width + while ( + len(job_markers) > 1 + and progress.custom_len(''.join(job_markers)) > width + ): + job_markers.pop(0) + + marker = ''.join(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..54f1aaa3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,227 @@ +[build-system] +build-backend = 'setuptools.build_meta' +# setuptools-scm is required at build time: its setuptools file-finder +# entry point is what populates the sdist with the full set of +# git-tracked files (docs/, tests/ data, tox.ini, .github/, ...). +# Dropping it silently shrinks the sdist and omits the test harness, so +# it must stay even though the version itself is static (see +# tool.setuptools.dynamic below). +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] +# Optional native iterator accelerator. When installed it is detected and used +# automatically (the iterator path drops to ~5 ns/iter); otherwise progressbar2 +# falls back to the pure-Python gate. See the Performance section in README. +fast = [ + 'speedups>=2.1.0', +] +docs = [ + 'sphinx>=1.8.5', + 'sphinx-autodoc-typehints>=1.6.0', +] +tests = [ + 'dill>=0.3.6', + 'freezegun>=0.3.11', + 'pytest-cov>=2.6.1', + 'pytest>=4.6.9', + 'pywin32; sys_platform == "win32"', +] + +[dependency-groups] +dev = ['progressbar2[docs,tests]'] + +[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:', + 'if typing.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 2a7f089b..6cec5595 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,22 +1,26 @@ [pytest] python_files = - progressbar/*.py tests/*.py +# Coverage flags intentionally live in the tox [testenv] commands, not +# here, so a bare `pytest -k foo` stays fast. CI enforces the 100% gate +# via tox. `--doctest-modules` still collects progressbar/*.py doctests +# regardless of `python_files` (which only scopes test-module collection). addopts = - --cov progressbar - --cov-report term-missing - --cov-report html --doctest-modules - --pep8 - --flakes - progressbar - tests -pep8ignore = - *.py W391 - docs/*.py ALL +norecursedirs = + .* + _* + build + benchmarks + 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.txt b/requirements.txt deleted file mode 100644 index 68b73027..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# In case of future requirements diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..eb467554 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,115 @@ +# We keep the ruff configuration separate so it can easily be shared across +# all projects + +target-version = 'py310' + +#src = ['progressbar'] +exclude = [ + '.venv', + '.tox', + # Ignore local test files/directories/old-stuff + 'test.py', + '*_old.py', + # Benchmark/tooling scripts are not held to the package lint standard + 'benchmarks', +] + +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/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..69377900 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Helper scripts for maintainers.""" diff --git a/scripts/render_readme_demos.py b/scripts/render_readme_demos.py new file mode 100644 index 00000000..f614350a --- /dev/null +++ b/scripts/render_readme_demos.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +import argparse +import html +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +STATIC_DIR = ROOT / 'docs' / '_static' +TIMING_FIELD_RE = re.compile( + r'\b(Elapsed Time|ETA|Time):(\s+)\d+:\d{2}:\d{2}', +) +BAR_RE = re.compile(r'\|(?P(?:#+[\s#]*|))\|') +PERCENT_RE = re.compile(r'\b\d{1,3}%') +POSTFIX_RE = re.compile(r'\b[A-Za-z_][\w-]*=[^\s,]+') +LABEL_RE = re.compile(r'[A-Za-z][\w-]*:?') +ANSI_SGR_RE = re.compile(r'\x1b\[([0-9;]*)m') +ANIMATION_FRAME_SECONDS = 0.08 +MAX_ANIMATION_FRAMES = 24 +SVG_WIDTH = 1080 + + +@dataclass(frozen=True) +class Demo: + name: str + title: str + snippet: str + log_lines: int = 0 + + +DEMOS = [ + Demo( + 'hero', + 'Progress with clean logs', + """ +import sys +import time +import progressbar + +with progressbar.ProgressBar( + total=24, + desc='Build', + fd=sys.stdout, + redirect_stdout=True, + line_breaks=False, + is_terminal=True, + enable_colors=True, + term_width=112, +) as bar: + for step in range(24): + if step in {8, 16}: + print(f'log: completed step {step}') + bar.update(step + 1, force=True) + time.sleep(0.005) +""", + log_lines=2, + ), + Demo( + 'multibar', + 'Multiple active jobs', + """ +import io +import re +import progressbar + +fd = io.StringIO() +multibar = progressbar.MultiBar( + fd=fd, + total=24, + enable_colors=True, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + term_width=112, +) +build = multibar['build'] +test = multibar['test'] +terminal_control_re = re.compile(r'\\x1b\\[[0-9;]*[A-Za-ln-z]') + +def emit_frame(): + output = terminal_control_re.sub('', fd.getvalue()) + for line in output.split('\\r'): + line = line.strip() + if line: + print(line) + print('\\f', end='') + fd.seek(0) + fd.truncate(0) + +multibar.render(force=True, flush=True) +emit_frame() + +for step in range(24): + build.update(step + 1, force=True) + test_value = min(24, max(0, round((step - 3) * 1.2))) + test.update(test_value, force=True) + multibar.render(force=True, flush=True) + emit_frame() +""", + ), + Demo( + 'unknown-length', + 'Unknown length', + """ +import sys +import progressbar + +with progressbar.ProgressBar( + max_value=progressbar.UnknownLength, + fd=sys.stdout, + line_breaks=False, + is_terminal=True, + enable_colors=True, + term_width=112, +) as bar: + for value in range(0, 120, 10): + bar.update(value, force=True) +""", + ), +] + + +def capture_demo(demo: Demo) -> list[list[str]]: + env = os.environ.copy() + env['COLORFGBG'] = '15;0' + env['COLORTERM'] = 'truecolor' + env['PYTHONPATH'] = str(ROOT) + env['PYTHONIOENCODING'] = 'utf-8' + try: + result = subprocess.run( + [sys.executable, '-c', demo.snippet], + cwd=ROOT, + env=env, + text=True, + encoding='utf-8', + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + timeout=5, + ) + except subprocess.TimeoutExpired as error: + raise SystemExit(f'timed out capturing demo: {demo.name}') from error + + frames = parse_frames(result.stdout) + if demo.log_lines: + frames = keep_recent_logs_with_progress(frames, demo.log_lines) + return limit_animation_frames(frames) or [['No output captured']] + + +def normalize_terminal_line(line: str) -> str: + return TIMING_FIELD_RE.sub( + lambda match: f'{match.group(1)}:{match.group(2)}0:00:00', + line, + ) + + +def frames_from_output(output: str) -> list[list[str]]: + return limit_animation_frames(parse_frames(output)) or [ + ['No output captured'] + ] + + +def parse_frames(output: str) -> list[list[str]]: + frames: list[list[str]] = [] + output = output.replace('\x1b[2K', '') + if '\f' in output: + for raw_frame in output.split('\f'): + lines = [ + normalize_terminal_line(line.strip()) + for line in raw_frame.splitlines() + if line.strip() + ] + if lines: + frames.append(lines) + return frames + + for raw_frame in output.splitlines(): + for part in raw_frame.split('\r'): + line = part.strip() + if line: + frames.append([normalize_terminal_line(line)]) + return frames + + +def keep_recent_logs_with_progress( + frames: list[list[str]], + log_lines: int, +) -> list[list[str]]: + logs: list[str] = [] + output: list[list[str]] = [] + + for frame in frames: + log_frame = [line for line in frame if line.startswith('log:')] + progress_frame = [ + line for line in frame if not line.startswith('log:') + ] + if log_frame: + logs.extend(log_frame) + logs = logs[-log_lines:] + if progress_frame: + output.append(logs + progress_frame) + + return output + + +def limit_animation_frames(frames: list[list[str]]) -> list[list[str]]: + if len(frames) <= MAX_ANIMATION_FRAMES: + return frames + + last_index = len(frames) - 1 + selected = [ + round(index * last_index / (MAX_ANIMATION_FRAMES - 1)) + for index in range(MAX_ANIMATION_FRAMES) + ] + return [frames[index] for index in selected] + + +def tspan( + text: str, + class_name: str | None = None, + style: str | None = None, +) -> str: + if not text: + return '' + escaped = html.escape(text) + if style is not None: + return f'{escaped}' + if class_name is None: + return escaped + return f'{escaped}' + + +def xterm_256_to_rgb(color: int) -> tuple[int, int, int]: + if color < 16: + palette = ( + (0, 0, 0), + (128, 0, 0), + (0, 128, 0), + (128, 128, 0), + (0, 0, 128), + (128, 0, 128), + (0, 128, 128), + (192, 192, 192), + (128, 128, 128), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), + ) + return palette[max(0, color)] + + if color < 232: + color -= 16 + levels = (0, 95, 135, 175, 215, 255) + return ( + levels[color // 36], + levels[(color // 6) % 6], + levels[color % 6], + ) + + shade = 8 + (color - 232) * 10 + return shade, shade, shade + + +def ansi_rgb_style(red: int, green: int, blue: int) -> str: + return f'fill: #{red:02x}{green:02x}{blue:02x}' + + +def ansi_sgr_style(parameters: str, current_style: str | None) -> str | None: + codes = [int(code) if code else 0 for code in parameters.split(';')] + index = 0 + while index < len(codes): + code = codes[index] + if code in {0, 39}: + current_style = None + elif code == 38 and index + 1 < len(codes): + mode = codes[index + 1] + if mode == 2 and index + 4 < len(codes): + current_style = ansi_rgb_style( + codes[index + 2], + codes[index + 3], + codes[index + 4], + ) + index += 4 + elif mode == 5 and index + 2 < len(codes): + current_style = ansi_rgb_style( + *xterm_256_to_rgb(codes[index + 2]), + ) + index += 2 + else: + index += 1 + index += 1 + + return current_style + + +def styled_ansi_terminal_line(line: str) -> str: + output: list[str] = [] + cursor = 0 + current_style: str | None = None + for match in ANSI_SGR_RE.finditer(line): + output.append(tspan(line[cursor : match.start()], style=current_style)) + current_style = ansi_sgr_style(match.group(1), current_style) + cursor = match.end() + + output.append(tspan(line[cursor:], style=current_style)) + return ''.join(output) + + +def styled_text_segment( + text: str, + absolute_start: int, + full_line: str, +) -> str: + ranges: list[tuple[int, int, str]] = [] + if absolute_start == 0 and text.startswith('log:'): + ranges.append((0, 4, 'terminal-log')) + elif ( + absolute_start == 0 + and '%' in full_line + and (label_match := LABEL_RE.match(text)) + ): + ranges.append( + (label_match.start(), label_match.end(), 'terminal-label') + ) + + ranges.extend( + (match.start(), match.end(), 'terminal-percent') + for match in PERCENT_RE.finditer(text) + ) + ranges.extend( + (match.start(), match.end(), 'terminal-postfix') + for match in POSTFIX_RE.finditer(text) + ) + + output: list[str] = [] + cursor = 0 + for start, end, class_name in sorted(ranges): + if start < cursor: + continue + output.append(tspan(text[cursor:start])) + output.append(tspan(text[start:end], class_name)) + cursor = end + output.append(tspan(text[cursor:])) + return ''.join(output) + + +def styled_bar_segment(inner: str) -> str: + output = [tspan('|', 'terminal-bar-frame')] + for match in re.finditer(r'#+|\s+|[^#\s]+', inner): + value = match.group(0) + if set(value) == {'#'}: + class_name = 'terminal-bar-fill' + elif value.isspace(): + class_name = 'terminal-bar-empty' + else: + class_name = 'terminal-bar-text' + output.append(tspan(value, class_name)) + output.append(tspan('|', 'terminal-bar-frame')) + return ''.join(output) + + +def styled_terminal_line(line: str) -> str: + if '\x1b[' in line: + return styled_ansi_terminal_line(line) + + output: list[str] = [] + cursor = 0 + for match in BAR_RE.finditer(line): + output.append( + styled_text_segment(line[cursor : match.start()], cursor, line) + ) + output.append(styled_bar_segment(match.group('inner'))) + cursor = match.end() + output.append(styled_text_segment(line[cursor:], cursor, line)) + return ''.join(output) + + +def svg_document(title: str, frames: list[list[str]]) -> str: + width = SVG_WIDTH + line_height = 24 + max_lines = max(len(frame) for frame in frames) + height = 72 + max_lines * line_height + duration = f'{max(len(frames), 1) * ANIMATION_FRAME_SECONDS:g}' + frame_groups = [] + for index, frame in enumerate(frames): + visible_values = ['0'] * len(frames) + visible_values[index] = '1' + visible_value_list = ';'.join(visible_values) + base_opacity = '1' if index == 0 else '0' + lines = [] + for row, line in enumerate(frame): + lines.append( + f'' + f'{styled_terminal_line(line)}' + ) + frame_groups.append( + f'' + '' + ''.join(lines) + '' + ) + + return f''' + + + + + + {html.escape(title)} + {''.join(frame_groups)} + +''' + + +def render_svg(path: Path, title: str, frames: list[list[str]]) -> None: + svg = svg_document(title, frames) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(svg, encoding='utf-8') + + +def check_svg(path: Path, expected: str) -> None: + if not path.exists(): + raise SystemExit(f'missing generated asset: {path}') + if path.read_text(encoding='utf-8') != expected: + raise SystemExit(f'outdated generated asset: {path}') + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + for demo in DEMOS: + output = STATIC_DIR / f'progressbar-{demo.name}.svg' + frames = capture_demo(demo) + if args.check: + check_svg(output, svg_document(demo.title, frames)) + else: + render_svg(output, demo.title, frames) + + +if __name__ == '__main__': + main() diff --git a/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 7f68775e..00000000 --- a/setup.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import os -import sys - - -try: - from setuptools import setup, find_packages - from setuptools.command.test import test as TestCommand -except ImportError: - from distutils.core import setup, find_packages, Command as TestCommand - -about = {} -with open("progressbar/__about__.py") as fp: - exec(fp.read(), about) - - -install_reqs = [] -tests_reqs = [] - -if sys.version_info < (2, 7): - install_reqs += ['argparse'] - tests_reqs += ['unittest2'] - - -def parse_requirements(filename): - '''Read the requirements from the filename, supports includes''' - requirements = [] - - if os.path.isfile(filename): - with open(filename) as fh: - for line in fh: - line = line.strip() - if line.startswith('-r'): - requirements += parse_requirements( - os.path.join(os.path.dirname(filename), - line.split(' ', 1)[-1])) - elif line and not line.startswith('#'): - requirements.append(line) - - return requirements - -install_reqs += parse_requirements('requirements.txt') -tests_reqs += parse_requirements('tests/requirements.txt') - -if sys.argv[-1] == 'info': - for k, v in about.items(): - print('%s: %s' % (k, v)) - sys.exit() - -with open('README.rst') as fh: - readme = fh.read() - - -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) - -setup( - name=about['__package_name__'], - version=about['__version__'], - author=about['__author__'], - author_email=about['__email__'], - description=about['__description__'], - url=about['__url__'], - license=about['__license__'], - keywords=about['__title__'], - packages=find_packages(exclude=['docs']), - long_description=readme, - include_package_data=True, - install_requires=install_reqs, - tests_require=tests_reqs, - zip_safe=False, - cmdclass={'test': PyTest}, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Natural Language :: English', - "Programming Language :: Python :: 2", - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: Implementation :: PyPy', - ], -) diff --git a/tests/api_surface_snapshot.json b/tests/api_surface_snapshot.json new file mode 100644 index 00000000..86820c50 --- /dev/null +++ b/tests/api_surface_snapshot.json @@ -0,0 +1,544 @@ +{ + "progressbar": { + "AbsoluteETA": "class(format_not_started=?, format_finished=?, format=?, **kwargs)", + "AdaptiveETA": "class(exponential_smoothing=?, exponential_smoothing_factor=?, **kwargs)", + "AdaptiveTransferSpeed": "class(**kwargs)", + "AnimatedMarker": "class(markers=?, default=?, fill=?, marker_wrap=?, fill_wrap=?, **kwargs)", + "Bar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", + "BouncingBar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", + "Counter": "class(format=?, **kwargs)", + "CurrentTime": "class(format=?, microseconds=?, **kwargs)", + "DataSize": "class(variable=?, format=?, unit=?, prefixes=?, **kwargs)", + "DataTransferBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)", + "DoubleExponentialMovingAverage": "class(alpha=?)", + "DynamicMessage": "class(name, format=?, width=?, precision=?, **kwargs)", + "ETA": "class(format_not_started=?, format_finished=?, format=?, format_zero=?, format_na=?, **kwargs)", + "ExponentialMovingAverage": "class(alpha=?)", + "FastProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)", + "FileTransferSpeed": "class(format=?, inverse_format=?, unit=?, prefixes=?, **kwargs)", + "FormatCustomText": "class(format, mapping=?, **kwargs)", + "FormatLabel": "class(format, **kwargs)", + "FormatLabelBar": "class(format, **kwargs)", + "GranularBar": "class(markers=?, left=?, right=?, **kwargs)", + "JobStatusBar": "class(name, left=?, right=?, fill=?, fill_left=?, success_fg_color=?, success_bg_color=?, success_marker=?, failure_fg_color=?, failure_bg_color=?, failure_marker=?, **kwargs)", + "LineOffsetStreamWrapper": "class(lines=?, stream=?)", + "MultiBar": "class(bars=?, fd=?, prepend_label=?, append_label=?, label_format=?, initial_format=?, finished_format=?, update_interval=?, show_initial=?, show_finished=?, remove_finished=?, sort_key=?, sort_reverse=?, sort_keyfunc=?, *, join_timeout=?, **progressbar_kwargs)", + "MultiProgressBar": "class(name, markers=?, **kwargs)", + "MultiRangeBar": "class(name, markers, **kwargs)", + "NullBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)", + "Percentage": "class(format=?, na=?, **kwargs)", + "PercentageLabelBar": "class(format=?, na=?, **kwargs)", + "Postfix": "class(name=?, prefix=?, separator=?, **kwargs)", + "ProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)", + "ReverseBar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, **kwargs)", + "RotatingMarker": "class(markers=?, default=?, fill=?, marker_wrap=?, fill_wrap=?, **kwargs)", + "SimpleProgress": "class(format=?, **kwargs)", + "SmoothingAlgorithm": "class(**kwargs)", + "SmoothingETA": "class(smoothing_algorithm=?, smoothing_parameters=?, **kwargs)", + "SortKey": "enum(CREATED,LABEL,VALUE,PERCENTAGE)", + "Timer": "class(format=?, **kwargs)", + "UnitProgress": "class(unit=?, unit_scale=?, **kwargs)", + "UnknownLength": "class()", + "Variable": "class(name, format=?, width=?, precision=?, **kwargs)", + "VariableMixin": "class(name, **kwargs)", + "__author__": "str", + "__version__": "str", + "len_color": "callable(value)", + "progressbar": "callable(iterator, min_value=?, max_value=?, widgets=?, prefix=?, suffix=?, fast=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)", + "streams": "StreamWrapper" + }, + "progressbar.algorithms": { + "DoubleExponentialMovingAverage": "class(alpha=?)", + "ExponentialMovingAverage": "class(alpha=?)", + "SmoothingAlgorithm": "class(**kwargs)", + "annotations": "_Feature", + "timedelta": "re-export" + }, + "progressbar.bar": { + "DataTransferBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)", + "DefaultFdMixin": "class(fd=?, is_terminal=?, line_breaks=?, enable_colors=?, line_offset=?, **kwargs)", + "FrameType": "re-export", + "NullBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)", + "NumberT": "re-export", + "ProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)", + "ProgressBarBase": "class(**kwargs)", + "ProgressBarMixinBase": "class(**kwargs)", + "ResizableMixin": "class(term_width=?, **kwargs)", + "StdRedirectMixin": "class(redirect_stderr=?, redirect_stdout=?, redirect_blank_line=?, **kwargs)", + "T": "type-alias", + "ValueT": "type-alias", + "annotations": "_Feature", + "datetime": "re-export", + "deepcopy": "callable(x, memo=?, _nil=?)", + "logger": "Logger" + }, + "progressbar.base": { + "FalseMeta": "classsignature-unavailable", + "IO": "re-export", + "TextIO": "re-export", + "Undefined": "class()", + "UnknownLength": "class()" + }, + "progressbar.env": { + "ANSI_TERMS": "tuple", + "ANSI_TERM_RE": "Pattern", + "COLOR_SUPPORT": "ColorSupport", + "ColorSupport": "enum(NONE,XTERM,XTERM_256,XTERM_TRUECOLOR,WINDOWS)", + "JUPYTER": "bool", + "annotations": "_Feature", + "env_flag": "callable(name, default=?)", + "is_ansi_terminal": "callable(fd, is_terminal=?)", + "is_terminal": "callable(fd, is_terminal=?)" + }, + "progressbar.fast": { + "Callable": "re-export", + "FastProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)", + "annotations": "_Feature", + "datetime": "re-export", + "timedelta": "re-export" + }, + "progressbar.multi": { + "MultiBar": "class(bars=?, fd=?, prepend_label=?, append_label=?, label_format=?, initial_format=?, finished_format=?, update_interval=?, show_initial=?, show_finished=?, remove_finished=?, sort_key=?, sort_reverse=?, sort_keyfunc=?, *, join_timeout=?, **progressbar_kwargs)", + "SortKey": "enum(CREATED,LABEL,VALUE,PERCENTAGE)", + "SortKeyFunc": "type-alias", + "annotations": "_Feature", + "timedelta": "re-export" + }, + "progressbar.shortcuts": { + "T": "type-alias", + "annotations": "_Feature", + "progressbar": "callable(iterator, min_value=?, max_value=?, widgets=?, prefix=?, suffix=?, fast=?, desc=?, total=?, unit=?, unit_scale=?, postfix=?, **kwargs)" + }, + "progressbar.terminal": { + "CLEAR_LINE": "callable()", + "CLEAR_LINE_ALL": "callable(*args)", + "CLEAR_LINE_LEFT": "callable()", + "CLEAR_LINE_RIGHT": "callable()", + "CLEAR_SCREEN": "callable(*args)", + "CLEAR_SCREEN_ALL": "callable()", + "CLEAR_SCREEN_ALL_AND_HISTORY": "callable()", + "CLEAR_SCREEN_TILL_END": "callable()", + "CLEAR_SCREEN_TILL_START": "callable()", + "COLUMN": "callable(*args)", + "CSI": "class(code, *default_args)", + "CSINoArg": "class(code, *default_args)", + "CUP": "callable(*args)", + "ClassVar": "type-alias", + "Color": "class(rgb, hls, name, xterm)", + "ColorBase": "class()", + "ColorGradient": "class(*colors, interpolate=?)", + "Colors": "class()", + "DOWN": "callable(*args)", + "DummyColor": "class()", + "ESC": "str", + "Generator": "re-export", + "HIDE_CURSOR": "callable()", + "HSL": "class(hue, saturation, lightness)", + "Iterable": "re-export", + "Iterator": "re-export", + "LEFT": "callable(*args)", + "LastLineStream": "class(stream)", + "LineOffsetStreamWrapper": "class(lines=?, stream=?)", + "NEXT_LINE": "callable(*args)", + "OptionalColor": "type-alias", + "PREVIOUS_LINE": "callable(*args)", + "RESTORE_CURSOR": "callable()", + "RGB": "class(red, green, blue)", + "RIGHT": "callable(*args)", + "SAVE_CURSOR": "callable()", + "SCROLL_DOWN": "callable(*args)", + "SCROLL_UP": "callable(*args)", + "SGR": "class(start_code, end_code)", + "SGRColor": "class(color, start_code, end_code)", + "SHOW_CURSOR": "callable()", + "TextIOOutputWrapper": "class(stream)", + "TracebackType": "re-export", + "UP": "callable(*args)", + "WindowsColor": "class(color)", + "WindowsColors": "enum(BLACK,BLUE,GREEN,CYAN,RED,MAGENTA,YELLOW,GREY,INTENSE_BLACK,INTENSE_BLUE,INTENSE_GREEN,INTENSE_CYAN,INTENSE_RED,INTENSE_MAGENTA,INTENSE_YELLOW,INTENSE_WHITE)", + "annotations": "_Feature", + "apply_colors": "callable(text, percentage=?, *, fg=?, bg=?, fg_none=?, bg_none=?, **kwargs)", + "bold": "callable(text, *args)", + "clear_line": "callable(n)", + "defaultdict": "re-export", + "double_underline": "callable(text, *args)", + "encircled": "callable(text, *args)", + "faint": "callable(text, *args)", + "fast_blink": "callable(text, *args)", + "framed": "callable(text, *args)", + "get_color": "callable(value, color)", + "getch": "callable()", + "gothic": "callable(text, *args)", + "inverse": "callable(text, *args)", + "italic": "callable(text, *args)", + "overline": "callable(text, *args)", + "slow_blink": "callable(text, *args)", + "strike_through": "callable(text, *args)", + "underline": "callable(text, *args)" + }, + "progressbar.terminal.base": { + "CLEAR_LINE": "callable()", + "CLEAR_LINE_ALL": "callable(*args)", + "CLEAR_LINE_LEFT": "callable()", + "CLEAR_LINE_RIGHT": "callable()", + "CLEAR_SCREEN": "callable(*args)", + "CLEAR_SCREEN_ALL": "callable()", + "CLEAR_SCREEN_ALL_AND_HISTORY": "callable()", + "CLEAR_SCREEN_TILL_END": "callable()", + "CLEAR_SCREEN_TILL_START": "callable()", + "COLUMN": "callable(*args)", + "CSI": "class(code, *default_args)", + "CSINoArg": "class(code, *default_args)", + "CUP": "callable(*args)", + "ClassVar": "type-alias", + "Color": "class(rgb, hls, name, xterm)", + "ColorBase": "class()", + "ColorGradient": "class(*colors, interpolate=?)", + "Colors": "class()", + "DOWN": "callable(*args)", + "DummyColor": "class()", + "ESC": "str", + "HIDE_CURSOR": "callable()", + "HSL": "class(hue, saturation, lightness)", + "LEFT": "callable(*args)", + "NEXT_LINE": "callable(*args)", + "OptionalColor": "type-alias", + "PREVIOUS_LINE": "callable(*args)", + "RESTORE_CURSOR": "callable()", + "RGB": "class(red, green, blue)", + "RIGHT": "callable(*args)", + "SAVE_CURSOR": "callable()", + "SCROLL_DOWN": "callable(*args)", + "SCROLL_UP": "callable(*args)", + "SGR": "class(start_code, end_code)", + "SGRColor": "class(color, start_code, end_code)", + "SHOW_CURSOR": "callable()", + "UP": "callable(*args)", + "WindowsColor": "class(color)", + "WindowsColors": "enum(BLACK,BLUE,GREEN,CYAN,RED,MAGENTA,YELLOW,GREY,INTENSE_BLACK,INTENSE_BLUE,INTENSE_GREEN,INTENSE_CYAN,INTENSE_RED,INTENSE_MAGENTA,INTENSE_YELLOW,INTENSE_WHITE)", + "annotations": "_Feature", + "apply_colors": "callable(text, percentage=?, *, fg=?, bg=?, fg_none=?, bg_none=?, **kwargs)", + "bold": "callable(text, *args)", + "clear_line": "callable(n)", + "defaultdict": "re-export", + "double_underline": "callable(text, *args)", + "encircled": "callable(text, *args)", + "faint": "callable(text, *args)", + "fast_blink": "callable(text, *args)", + "framed": "callable(text, *args)", + "get_color": "callable(value, color)", + "getch": "callable()", + "gothic": "callable(text, *args)", + "inverse": "callable(text, *args)", + "italic": "callable(text, *args)", + "overline": "callable(text, *args)", + "slow_blink": "callable(text, *args)", + "strike_through": "callable(text, *args)", + "underline": "callable(text, *args)" + }, + "progressbar.terminal.colors": { + "ColorGradient": "class(*colors, interpolate=?)", + "Colors": "class()", + "HSL": "class(hue, saturation, lightness)", + "RGB": "class(red, green, blue)", + "annotations": "_Feature", + "aqua": "callable(value)", + "aquamarine1": "callable(value)", + "aquamarine3": "callable(value)", + "bg_gradient": "callable(value)", + "black": "callable(value)", + "blue": "callable(value)", + "blue1": "callable(value)", + "blue3": "callable(value)", + "blue_violet": "callable(value)", + "cadet_blue": "callable(value)", + "chartreuse1": "callable(value)", + "chartreuse2": "callable(value)", + "chartreuse3": "callable(value)", + "chartreuse4": "callable(value)", + "cornflower_blue": "callable(value)", + "cornsilk1": "callable(value)", + "cyan1": "callable(value)", + "cyan2": "callable(value)", + "cyan3": "callable(value)", + "dark_blue": "callable(value)", + "dark_cyan": "callable(value)", + "dark_goldenrod": "callable(value)", + "dark_gradient": "callable(value)", + "dark_green": "callable(value)", + "dark_khaki": "callable(value)", + "dark_magenta": "callable(value)", + "dark_olive_green1": "callable(value)", + "dark_olive_green2": "callable(value)", + "dark_olive_green3": "callable(value)", + "dark_orange": "callable(value)", + "dark_orange3": "callable(value)", + "dark_red": "callable(value)", + "dark_sea_green": "callable(value)", + "dark_sea_green1": "callable(value)", + "dark_sea_green2": "callable(value)", + "dark_sea_green3": "callable(value)", + "dark_sea_green4": "callable(value)", + "dark_slate_gray1": "callable(value)", + "dark_slate_gray2": "callable(value)", + "dark_slate_gray3": "callable(value)", + "dark_turquoise": "callable(value)", + "dark_violet": "callable(value)", + "deep_pink1": "callable(value)", + "deep_pink2": "callable(value)", + "deep_pink3": "callable(value)", + "deep_pink4": "callable(value)", + "deep_sky_blue1": "callable(value)", + "deep_sky_blue2": "callable(value)", + "deep_sky_blue3": "callable(value)", + "deep_sky_blue4": "callable(value)", + "dodger_blue1": "callable(value)", + "dodger_blue2": "callable(value)", + "dodger_blue3": "callable(value)", + "fuchsia": "callable(value)", + "gold1": "callable(value)", + "gold3": "callable(value)", + "gradient": "callable(value)", + "green": "callable(value)", + "green1": "callable(value)", + "green3": "callable(value)", + "green4": "callable(value)", + "green_yellow": "callable(value)", + "grey": "callable(value)", + "grey0": "callable(value)", + "grey100": "callable(value)", + "grey11": "callable(value)", + "grey15": "callable(value)", + "grey19": "callable(value)", + "grey23": "callable(value)", + "grey27": "callable(value)", + "grey3": "callable(value)", + "grey30": "callable(value)", + "grey35": "callable(value)", + "grey37": "callable(value)", + "grey39": "callable(value)", + "grey42": "callable(value)", + "grey46": "callable(value)", + "grey50": "callable(value)", + "grey53": "callable(value)", + "grey54": "callable(value)", + "grey58": "callable(value)", + "grey62": "callable(value)", + "grey63": "callable(value)", + "grey66": "callable(value)", + "grey69": "callable(value)", + "grey7": "callable(value)", + "grey70": "callable(value)", + "grey74": "callable(value)", + "grey78": "callable(value)", + "grey82": "callable(value)", + "grey84": "callable(value)", + "grey85": "callable(value)", + "grey89": "callable(value)", + "grey93": "callable(value)", + "honeydew2": "callable(value)", + "hot_pink": "callable(value)", + "hot_pink2": "callable(value)", + "hot_pink3": "callable(value)", + "indian_red": "callable(value)", + "indian_red1": "callable(value)", + "khaki1": "callable(value)", + "khaki3": "callable(value)", + "light_coral": "callable(value)", + "light_cyan1": "callable(value)", + "light_cyan3": "callable(value)", + "light_goldenrod1": "callable(value)", + "light_goldenrod2": "callable(value)", + "light_goldenrod3": "callable(value)", + "light_gradient": "callable(value)", + "light_green": "callable(value)", + "light_pink1": "callable(value)", + "light_pink3": "callable(value)", + "light_pink4": "callable(value)", + "light_salmon1": "callable(value)", + "light_salmon3": "callable(value)", + "light_sea_green": "callable(value)", + "light_sky_blue1": "callable(value)", + "light_sky_blue3": "callable(value)", + "light_slate_blue": "callable(value)", + "light_slate_grey": "callable(value)", + "light_steel_blue": "callable(value)", + "light_steel_blue1": "callable(value)", + "light_steel_blue3": "callable(value)", + "light_yellow3": "callable(value)", + "lime": "callable(value)", + "magenta1": "callable(value)", + "magenta2": "callable(value)", + "magenta3": "callable(value)", + "maroon": "callable(value)", + "medium_orchid": "callable(value)", + "medium_orchid1": "callable(value)", + "medium_orchid3": "callable(value)", + "medium_purple": "callable(value)", + "medium_purple1": "callable(value)", + "medium_purple2": "callable(value)", + "medium_purple3": "callable(value)", + "medium_purple4": "callable(value)", + "medium_spring_green": "callable(value)", + "medium_turquoise": "callable(value)", + "medium_violet_red": "callable(value)", + "misty_rose1": "callable(value)", + "misty_rose3": "callable(value)", + "navajo_white1": "callable(value)", + "navajo_white3": "callable(value)", + "navy": "callable(value)", + "navy_blue": "callable(value)", + "olive": "callable(value)", + "orange1": "callable(value)", + "orange3": "callable(value)", + "orange4": "callable(value)", + "orange_red1": "callable(value)", + "orchid": "callable(value)", + "orchid1": "callable(value)", + "orchid2": "callable(value)", + "pale_green1": "callable(value)", + "pale_green3": "callable(value)", + "pale_turquoise1": "callable(value)", + "pale_turquoise4": "callable(value)", + "pale_violet_red1": "callable(value)", + "pink1": "callable(value)", + "pink3": "callable(value)", + "plum1": "callable(value)", + "plum2": "callable(value)", + "plum3": "callable(value)", + "plum4": "callable(value)", + "primary": "callable(value)", + "purple": "callable(value)", + "purple3": "callable(value)", + "purple4": "callable(value)", + "red": "callable(value)", + "red1": "callable(value)", + "red3": "callable(value)", + "rosy_brown": "callable(value)", + "royal_blue1": "callable(value)", + "salmon1": "callable(value)", + "sandy_brown": "callable(value)", + "sea_green1": "callable(value)", + "sea_green2": "callable(value)", + "sea_green3": "callable(value)", + "silver": "callable(value)", + "sky_blue1": "callable(value)", + "sky_blue2": "callable(value)", + "sky_blue3": "callable(value)", + "slate_blue1": "callable(value)", + "slate_blue3": "callable(value)", + "spring_green1": "callable(value)", + "spring_green2": "callable(value)", + "spring_green3": "callable(value)", + "spring_green4": "callable(value)", + "steel_blue": "callable(value)", + "steel_blue1": "callable(value)", + "steel_blue3": "callable(value)", + "tan": "callable(value)", + "teal": "callable(value)", + "thistle1": "callable(value)", + "thistle3": "callable(value)", + "turquoise2": "callable(value)", + "turquoise4": "callable(value)", + "violet": "callable(value)", + "wheat1": "callable(value)", + "wheat4": "callable(value)", + "white": "callable(value)", + "yellow": "callable(value)", + "yellow1": "callable(value)", + "yellow2": "callable(value)", + "yellow3": "callable(value)", + "yellow4": "callable(value)" + }, + "progressbar.terminal.stream": { + "Generator": "re-export", + "Iterable": "re-export", + "Iterator": "re-export", + "LastLineStream": "class(stream)", + "LineOffsetStreamWrapper": "class(lines=?, stream=?)", + "TextIOOutputWrapper": "class(stream)", + "TracebackType": "re-export", + "annotations": "_Feature" + }, + "progressbar.utils": { + "AttributeDict": "classsignature-unavailable", + "Callable": "re-export", + "Iterable": "re-export", + "Iterator": "re-export", + "Mapping": "re-export", + "StreamWrapper": "class()", + "StringT": "type-alias", + "T": "type-alias", + "TracebackType": "re-export", + "WrappingIO": "class(target, capturing=?, listeners=?)", + "annotations": "_Feature", + "deltas_to_seconds": "callable(*deltas, default=?)", + "epoch": "datetime", + "format_time": "callable(timestamp, precision=?)", + "get_terminal_size": "callable()", + "len_color": "callable(value)", + "logger": "Logger", + "no_color": "callable(value)", + "scale_1024": "callable(x, n_prefixes)", + "streams": "StreamWrapper", + "timedelta_to_seconds": "callable(delta)" + }, + "progressbar.widgets": { + "AbsoluteETA": "class(format_not_started=?, format_finished=?, format=?, **kwargs)", + "AdaptiveETA": "class(exponential_smoothing=?, exponential_smoothing_factor=?, **kwargs)", + "AdaptiveTransferSpeed": "class(**kwargs)", + "AnimatedMarker": "class(markers=?, default=?, fill=?, marker_wrap=?, fill_wrap=?, **kwargs)", + "AutoWidthWidgetBase": "class(*args, fixed_colors=?, gradient_colors=?, **kwargs)", + "Bar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", + "BouncingBar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", + "ClassVar": "type-alias", + "ColoredMixin": "class()", + "Counter": "class(format=?, **kwargs)", + "CurrentTime": "class(format=?, microseconds=?, **kwargs)", + "DEFAULT_UNIT": "object", + "Data": "type-alias", + "DataSize": "class(variable=?, format=?, unit=?, prefixes=?, **kwargs)", + "DynamicMessage": "class(name, format=?, width=?, precision=?, **kwargs)", + "ETA": "class(format_not_started=?, format_finished=?, format=?, format_zero=?, format_na=?, **kwargs)", + "FileTransferSpeed": "class(format=?, inverse_format=?, unit=?, prefixes=?, **kwargs)", + "FormatCustomText": "class(format, mapping=?, **kwargs)", + "FormatLabel": "class(format, **kwargs)", + "FormatLabelBar": "class(format, **kwargs)", + "FormatString": "type-alias", + "FormatWidgetMixin": "class(format, new_style=?, **kwargs)", + "GranularBar": "class(markers=?, left=?, right=?, **kwargs)", + "GranularMarkers": "class()", + "JobStatusBar": "class(name, left=?, right=?, fill=?, fill_left=?, success_fg_color=?, success_bg_color=?, success_marker=?, failure_fg_color=?, failure_bg_color=?, failure_marker=?, **kwargs)", + "MAX_DATE": "date", + "MAX_DATETIME": "datetime", + "MAX_TIME": "time", + "MultiProgressBar": "class(name, markers=?, **kwargs)", + "MultiRangeBar": "class(name, markers, **kwargs)", + "Percentage": "class(format=?, na=?, **kwargs)", + "PercentageLabelBar": "class(format=?, na=?, **kwargs)", + "Postfix": "class(name=?, prefix=?, separator=?, **kwargs)", + "ReverseBar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, **kwargs)", + "RotatingMarker": "class(markers=?, default=?, fill=?, marker_wrap=?, fill_wrap=?, **kwargs)", + "SamplesMixin": "class(samples=?, key_prefix=?, **kwargs)", + "SimpleProgress": "class(format=?, **kwargs)", + "SmoothingETA": "class(smoothing_algorithm=?, smoothing_parameters=?, **kwargs)", + "T": "type-alias", + "TFixedColors": "type-alias", + "TGradientColors": "type-alias", + "TimeSensitiveWidgetBase": "class(*args, fixed_colors=?, gradient_colors=?, **kwargs)", + "Timer": "class(format=?, **kwargs)", + "UNIT_PREFIXES": "tuple", + "UnitProgress": "class(unit=?, unit_scale=?, **kwargs)", + "Variable": "class(name, format=?, width=?, precision=?, **kwargs)", + "VariableMixin": "class(name, **kwargs)", + "WidgetBase": "class(*args, fixed_colors=?, gradient_colors=?, **kwargs)", + "WidthWidgetMixin": "class(min_width=?, max_width=?, **kwargs)", + "annotations": "_Feature", + "create_marker": "callable(marker, wrap=?)", + "create_wrapper": "callable(wrapper)", + "format_unit_value": "callable(value, unit=?, unit_scale=?)", + "logger": "Logger", + "string_or_lambda": "callable(input_)", + "wrapper": "callable(function, wrapper_)" + } +} diff --git a/tests/conftest.py b/tests/conftest.py index ef853602..bad96e61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,18 @@ +from __future__ import annotations + +import collections.abc import logging +import time +import timeit +import typing +from datetime import datetime, timezone + +import freezegun +import pytest +import progressbar -LOG_LEVELS = { +LOG_LEVELS: dict[str, int] = { '0': logging.ERROR, '1': logging.WARNING, '2': logging.INFO, @@ -9,8 +20,61 @@ } -def pytest_configure(config): +def pytest_configure(config: pytest.Config) -> None: logging.basicConfig( - level=LOG_LEVELS.get(config.option.verbose, logging.DEBUG)) + level=LOG_LEVELS.get(config.option.verbose, logging.DEBUG), + ) + + +@pytest.fixture(autouse=True) +def disable_native_accelerator(monkeypatch: pytest.MonkeyPatch) -> None: + # The optional native accelerator (speedups.FastBarIterator) is exercised + # explicitly in test_native_accelerator.py. Every other test targets the + # pure-Python iterator (`_iter_python`), so force that path by default when + # the compiled `speedups` package happens to be installed in the dev/bench + # environment. Native tests restore it via their own monkeypatch. + import progressbar.bar as bar_module + + monkeypatch.setattr(bar_module, '_FastBarIterator', None) + + +@pytest.fixture(autouse=True) +def small_interval( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + # Tests marked `no_freezegun` need real timing conditions (e.g. the perf + # budget test), so preserve the default _MINIMUM_UPDATE_INTERVAL so the + # fast-path gate can calibrate and activate correctly. + if request.node.get_closest_marker('no_freezegun'): + return + # 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: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> collections.abc.Iterator[typing.Any]: + # Tests marked `no_freezegun` need a real, advancing clock (e.g. the + # gate's perf test, which only activates after a real timing measurement). + # For those, skip the freezegun wrapping entirely. + if request.node.get_closest_marker('no_freezegun'): + yield None + return + # 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/data.py b/tests/data.py deleted file mode 100644 index 039cffbb..00000000 --- a/tests/data.py +++ /dev/null @@ -1,21 +0,0 @@ -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): - widget = progressbar.DataSize() - assert widget(None, dict(value=value)) == expected diff --git a/tests/datatransferbar.py b/tests/datatransferbar.py deleted file mode 100644 index 495d6b7b..00000000 --- a/tests/datatransferbar.py +++ /dev/null @@ -1,16 +0,0 @@ -import progressbar -from progressbar import DataTransferBar - - -def test_known_length(): - dtb = DataTransferBar().start(max_value=50) - for i in range(50): - dtb.update(i) - dtb.finish() - - -def test_unknown_length(): - dtb = DataTransferBar().start(max_value=progressbar.UnknownLength) - for i in range(50): - dtb.update(i) - dtb.finish() diff --git a/tests/failure.py b/tests/failure.py deleted file mode 100644 index 217c3fe5..00000000 --- a/tests/failure.py +++ /dev/null @@ -1,94 +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) - - -def test_deprecated_maxval(): - progressbar.ProgressBar(maxval=5) - - -def test_deprecated_poll(): - progressbar.ProgressBar(poll=5) - - diff --git a/tests/iterators.py b/tests/iterators.py deleted file mode 100644 index 188809a3..00000000 --- a/tests/iterators.py +++ /dev/null @@ -1,57 +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() - - for i in p((i for i in range(10))): - time.sleep(0.001) - - assert p.max_value is progressbar.UnknownLength - - -def test_iterator_without_max_value(): - '''Progressbar can't guess max_value.''' - p = progressbar.ProgressBar(widgets=[ - progressbar.AnimatedMarker(), - progressbar.FormatLabel('%(value)d'), - progressbar.BouncingBar(), - progressbar.BouncingBar(marker=progressbar.RotatingMarker()), - ]) - for i in p((i for i in range(10))): - 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/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 index b42f4f81..9628e3fa 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,8 +1 @@ --r ../requirements.txt -flake8 -pytest -pytest-cache -pytest-cov -pytest-flakes -pytest-pep8 -sphinx +-e.[tests] diff --git a/tests/speed.py b/tests/speed.py deleted file mode 100644 index d7a338b3..00000000 --- a/tests/speed.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest -import progressbar - - -@pytest.mark.parametrize('total_seconds_elapsed,value,expected', [ - (1, 0, ' 0.0 s/B'), - (1, 0.01, '100.0 s/B'), - (1, 0.1, ' 0.1 B/s'), - (1, 1, ' 1.0 B/s'), - (1, 2 ** 10 - 1, '1023.0 B/s'), - (1, 2 ** 10 + 0, ' 1.0 KiB/s'), - (1, 2 ** 20, ' 1.0 MiB/s'), - (1, 2 ** 30, ' 1.0 GiB/s'), - (1, 2 ** 40, ' 1.0 TiB/s'), - (1, 2 ** 50, ' 1.0 PiB/s'), - (1, 2 ** 60, ' 1.0 EiB/s'), - (1, 2 ** 70, ' 1.0 ZiB/s'), - (1, 2 ** 80, ' 1.0 YiB/s'), - (1, 2 ** 90, '1024.0 YiB/s'), -]) -def test_file_transfer_speed(total_seconds_elapsed, value, expected): - 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 e29e750c..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.term_width 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.term_width is not None - for i in range(100): - p.update(i) - - -def test_auto_width(monkeypatch): - '''Right justify using the terminal width''' - - def 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.term_width 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.term_width 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.term_width 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_api_surface.py b/tests/test_api_surface.py new file mode 100644 index 00000000..18df63b9 --- /dev/null +++ b/tests/test_api_surface.py @@ -0,0 +1,178 @@ +"""Public API surface snapshot. + +Guards the backwards-compatibility contract while the quality-audit +refactors land: every public module keeps its public names, and every +public callable keeps its parameter names and kinds. + +The snapshot deliberately records only parameter *names* and *kinds* +(positional / keyword / var-positional / var-keyword) plus whether a +default exists. Annotations and default-value reprs are excluded so a +single snapshot is stable across Python 3.10-3.15 and so widening a type +annotation does not require a snapshot update. Removing or renaming a +parameter, changing its kind, or dropping a public name fails the test. + +Regenerate after a deliberate, reviewed API addition with: + + PROGRESSBAR_UPDATE_API_SNAPSHOT=1 pytest tests/test_api_surface.py +""" + +from __future__ import annotations + +import enum +import importlib +import inspect +import json +import os +import pathlib +import types +import typing + +import pytest + +SNAPSHOT_PATH: pathlib.Path = ( + pathlib.Path(__file__).parent / 'api_surface_snapshot.json' +) + +#: Modules whose public surface is under the compatibility contract. +PUBLIC_MODULES: tuple[str, ...] = ( + 'progressbar', + 'progressbar.algorithms', + 'progressbar.bar', + 'progressbar.base', + 'progressbar.env', + 'progressbar.fast', + 'progressbar.multi', + 'progressbar.shortcuts', + 'progressbar.terminal', + 'progressbar.terminal.base', + 'progressbar.terminal.colors', + 'progressbar.terminal.stream', + 'progressbar.utils', + 'progressbar.widgets', +) + + +def _describe_signature(obj: typing.Any) -> str: + """Return a version-stable signature descriptor for a callable.""" + try: + signature = inspect.signature(obj) + except (ValueError, TypeError): + return 'signature-unavailable' + + parts: list[str] = [] + for name, parameter in signature.parameters.items(): + prefix = { + inspect.Parameter.VAR_POSITIONAL: '*', + inspect.Parameter.VAR_KEYWORD: '**', + }.get(parameter.kind, '') + suffix = '=?' if parameter.default is not parameter.empty else '' + parts.append(f'{prefix}{name}{suffix}') + + if parameter.kind is inspect.Parameter.KEYWORD_ONLY and ( + '*' not in ''.join(parts[:-1]) + ): + # Mark the keyword-only boundary once so converting a + # positional parameter to keyword-only is visible. + parts.insert(len(parts) - 1, '*') + + return f'({", ".join(parts)})' + + +def _describe(obj: typing.Any) -> str: + # typing constructs (Union aliases, parameterized generics, TypeVars) + # change type/callability across Python versions (e.g. typing.Union + # aliases became instances of a Union class in 3.14), so they get one + # stable descriptor everywhere. + if ( + typing.get_origin(obj) is not None + or getattr(type(obj), '__module__', '') == 'typing' + ): + return 'type-alias' + if inspect.isclass(obj): + if issubclass(obj, enum.Enum): + # Enum constructor signatures are metaclass artifacts that vary + # across Python versions; the compatibility contract is the + # member list. + enum_class = typing.cast('type[enum.Enum]', obj) + members = ','.join(member.name for member in enum_class) + return f'enum({members})' + if not getattr(obj, '__module__', '').startswith('progressbar'): + # Stdlib/third-party re-exports (TracebackType, timedelta, ...) + # picked up by the no-__all__ fallback: their signatures are not + # part of this package's contract and vary across versions. + return 're-export' + return f'class{_describe_signature(obj)}' + if callable(obj): + return f'callable{_describe_signature(obj)}' + # Describe instances by their first non-freezegun class: if an earlier + # test imported a module under freezegun, module constants like + # widgets.MAX_DATE are Fake* instances forever, which would make this + # snapshot order-dependent (FakeDate vs date). + return next( + cls.__name__ + for cls in type(obj).__mro__ + if 'freezegun' not in (getattr(cls, '__module__', '') or '') + ) + + +def _public_names(module: types.ModuleType) -> list[str]: + explicit = getattr(module, '__all__', None) + if explicit is not None: + return sorted(explicit) + return sorted( + name + for name in dir(module) + if not name.startswith('_') + and not isinstance(getattr(module, name), types.ModuleType) + ) + + +def build_surface() -> dict[str, dict[str, str]]: + surface: dict[str, dict[str, str]] = {} + for module_name in PUBLIC_MODULES: + module = importlib.import_module(module_name) + surface[module_name] = { + name: _describe(getattr(module, name)) + for name in _public_names(module) + } + return surface + + +@pytest.mark.no_freezegun +def test_api_surface_snapshot() -> None: + # no_freezegun: the surface describes module constants by type name; + # freezegun would report FakeDate/FakeDatetime for MAX_DATE/MAX_DATETIME. + surface: dict[str, dict[str, str]] = build_surface() + + if os.environ.get('PROGRESSBAR_UPDATE_API_SNAPSHOT'): + SNAPSHOT_PATH.write_text( + json.dumps(surface, indent=2, sort_keys=True) + '\n', + ) + pytest.skip('API surface snapshot regenerated') + + assert SNAPSHOT_PATH.exists(), ( + 'Missing API snapshot; generate it with ' + 'PROGRESSBAR_UPDATE_API_SNAPSHOT=1 pytest tests/test_api_surface.py' + ) + snapshot: dict[str, dict[str, str]] = json.loads( + SNAPSHOT_PATH.read_text(), + ) + + problems: list[str] = [] + for module_name, expected in snapshot.items(): + current = surface.get(module_name) + if current is None: + problems.append(f'module removed: {module_name}') + continue + for name, descriptor in expected.items(): + if name not in current: + problems.append(f'{module_name}.{name}: removed') + elif current[name] != descriptor: + problems.append( + f'{module_name}.{name}: {descriptor} -> {current[name]}', + ) + + assert not problems, ( + 'Public API changed; if the change is a deliberate, reviewed ' + 'widening, regenerate the snapshot:\n' + '\n'.join(problems) + ) 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..bcb30716 --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,594 @@ +from __future__ import annotations + +import os + +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( + ('term', 'expected'), + [ + # Bare ``xterm`` and any ``xterm-*`` variant advertise (at least) 16 + # color support, matching the documented "if they contain ``xterm``" + # behaviour and ``is_ansi_terminal``'s ``^xterm`` prefix match. + ('xterm', env.ColorSupport.XTERM), + ('xterm-color', env.ColorSupport.XTERM), + ('xterm-16color', env.ColorSupport.XTERM), + # Every other ANSI_TERM_RE terminal is 16-color too, not NONE. + ('screen', env.ColorSupport.XTERM), + ('tmux', env.ColorSupport.XTERM), + ('konsole', env.ColorSupport.XTERM), + ('rxvt-unicode', env.ColorSupport.XTERM), + ('linux', env.ColorSupport.XTERM), + # A ``256`` anywhere in the value still wins over plain xterm. + ('xterm-256color', env.ColorSupport.XTERM_256), + ('screen-256color', env.ColorSupport.XTERM_256), + # TERM names that are themselves truecolor terminals engage 24-bit + # color even when COLORTERM is stripped (ssh, sudo). + ('xterm-kitty', env.ColorSupport.XTERM_TRUECOLOR), + ('xterm-ghostty', env.ColorSupport.XTERM_TRUECOLOR), + # Unknown terminals still mean no detected support. + ('dumb', env.ColorSupport.NONE), + ], +) +def test_color_support_from_env_term(monkeypatch, term, expected) -> 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('TERM', term) + assert env.ColorSupport.from_env() == expected + + +@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: 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: 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 + + +def test_registered_color_hls_matches_rgb() -> None: + # Regression: the hand-entered HSL column in colors.py had corrupted + # rows (e.g. DeepSkyBlue4 #005f87 stored hue 97 instead of 198), so + # gradients interpolated through the wrong hue. colors.py is now + # generated by tools/generate_colors.py, which derives every HSL from + # its RGB; this guards the two from ever drifting apart again. + for color in Colors.by_xterm.values(): + assert color.hls == terminal.HSL.from_rgb(color.rgb) + + +@pytest.mark.parametrize( + 'rgb, expected', + [ + (terminal.RGB(255, 0, 0), 1), + (terminal.RGB(128, 0, 0), 1), + (terminal.RGB(0, 128, 0), 2), + (terminal.RGB(0, 0, 128), 4), + (terminal.RGB(128, 128, 0), 3), + (terminal.RGB(0, 0, 0), 0), + (terminal.RGB(255, 255, 255), 7), + (terminal.RGB(127, 127, 127), 0), + ], +) +def test_rgb_to_ansi_16(rgb, expected) -> None: + # Regression: ``int(c / 255)`` is 1 only when a channel is exactly 255, so + # every mid-intensity colour (e.g. maroon 128,0,0) collapsed to black. A + # per-channel threshold at 128 maps each channel to its own bit. + assert rgb.to_ansi_16 == expected + + +@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_color_ansi_respects_support_level(monkeypatch) -> None: + # A registered colour with an xterm index and a non-black RGB. + color = terminal.Color( + terminal.RGB(128, 0, 0), + terminal.HSL(0, 100, 25), + 'maroon-test', + 52, + ) + + # 256-colour terminal: the registered xterm index is used verbatim. + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.XTERM_256) + assert color.ansi == '5;52' + + # 16-colour terminal: the 256-colour xterm index must NOT leak through; + # derive a 16-colour code from the RGB via to_ansi_16 instead. + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.XTERM) + assert color.ansi == f'5;{color.rgb.to_ansi_16}' + assert color.ansi != '5;52' + + +def test_color_ansi_black_xterm_zero(monkeypatch) -> None: + # Regression: ``if self.xterm:`` is falsy for index 0 (Black), so Black + # fell through to the RGB fallback instead of using its xterm index. + black = terminal.Color( + terminal.RGB(0, 0, 0), + terminal.HSL(0, 0, 0), + 'black-test', + 0, + ) + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.XTERM_256) + assert black.ansi == '5;0' + + +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 + + +class _PerInstanceColorWidget(progressbar.widgets.WidgetBase): + def __call__(self, *args, **kwargs) -> None: # pragma: no cover + pass + + +def test_fixed_colors_override_is_per_instance() -> None: + # Regression: F1 - passing ``fixed_colors`` to one instance mutated the + # shared class-level dict, rewriting colors for every other instance and + # subclass. The override must be copy-on-write. + class_default = dict(_PerInstanceColorWidget._fixed_colors) + override = widgets.TFixedColors(fg_none=colors.yellow, bg_none=None) + + a = _PerInstanceColorWidget(fixed_colors=override) + b = _PerInstanceColorWidget() + + assert _PerInstanceColorWidget._fixed_colors == class_default + assert a._fixed_colors['fg_none'] == colors.yellow + assert b._fixed_colors == class_default + assert a._fixed_colors is not override + + +def test_gradient_colors_override_is_per_instance() -> None: + # Regression: F1 - same copy-on-write requirement for ``gradient_colors``. + class_default = dict(_PerInstanceColorWidget._gradient_colors) + override = widgets.TGradientColors(fg=colors.gradient, bg=None) + + a = _PerInstanceColorWidget(gradient_colors=override) + b = _PerInstanceColorWidget() + + assert _PerInstanceColorWidget._gradient_colors == class_default + assert a._gradient_colors['fg'] == colors.gradient + assert b._gradient_colors == class_default + assert a._gradient_colors is not override + + +def test_sgr_color_without_ansi_leaves_text_unstyled(monkeypatch) -> None: + # Regression: when Color.ansi is None (no registered xterm index and no + # support level to derive a code from), SGRColor rendered a malformed + # '\x1b[38;Nonem' escape whose tail leaked into visible output as + # 'onem'. Without a usable color representation the text must pass + # through completely unstyled. + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.NONE) + unregistered = terminal.Color( + terminal.RGB(1, 2, 3), + terminal.HSL(0, 0, 1), + None, + None, + ) + assert unregistered.ansi is None + assert unregistered.fg('X') == 'X' + assert unregistered.bg('X') == 'X' + assert unregistered.underline('X') == 'X' + + +def test_registered_color_renders_when_forced_without_support( + monkeypatch, +) -> None: + # Callers can force colors (enable_colors=True) on terminals whose + # support detection reports NONE; a registered color must still render + # its xterm index there instead of silently dropping the styling. + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.NONE) + green = colors.green + assert green.ansi == '5;2' + assert green.fg('X') == '\x1b[38;5;2mX\x1b[39m' diff --git a/tests/test_custom_widgets.py b/tests/test_custom_widgets.py new file mode 100644 index 00000000..ce9f24ca --- /dev/null +++ b/tests/test_custom_widgets.py @@ -0,0 +1,131 @@ +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'] + + +def test_format_custom_text_mapping_is_per_instance() -> None: + # Regression: F2 - default-constructed FormatCustomText instances shared + # the mutable class-level ``mapping`` dict, so update_mapping on one bled + # into every other instance (and the class attribute). + class_default = dict(progressbar.FormatCustomText.mapping) + + a = progressbar.FormatCustomText('%(spam)s') + b = progressbar.FormatCustomText('%(spam)s') + + a.update_mapping(spam='eggs') + + assert a.mapping == {'spam': 'eggs'} + assert b.mapping == {} + assert a.mapping is not b.mapping + assert progressbar.FormatCustomText.mapping == class_default + + +def test_format_custom_text_subclass_keeps_class_default_mapping() -> None: + # A subclass may declare a class-level mapping default; instances + # constructed without an explicit mapping must inherit it (per + # instance, without aliasing the class dict). + class Defaulted(progressbar.FormatCustomText): + # The mutable class attribute is the point of this test: it mirrors + # how third-party subclasses declare default mappings. + mapping = {'spam': 'ham'} # noqa: RUF012 + + widget = Defaulted('%(spam)s') + assert widget.mapping == {'spam': 'ham'} + + widget.update_mapping(spam='eggs') + assert widget.mapping == {'spam': 'eggs'} + # The class default stays untouched by instance mutation. + assert Defaulted.mapping == {'spam': 'ham'} + assert Defaulted('%(spam)s').mapping == {'spam': 'ham'} 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_env_detection.py b/tests/test_env_detection.py new file mode 100644 index 00000000..8a393a0f --- /dev/null +++ b/tests/test_env_detection.py @@ -0,0 +1,95 @@ +"""Terminal-detection error handling in `progressbar.env`. + +`is_ansi_terminal` and `is_terminal` used to wrap their `fd.isatty()` +probing in blanket exception handlers. Only the failures a stream can +legitimately produce — `OSError` (real I/O), `ValueError` (closed or +detached file objects), `AttributeError` (objects without `isatty`) — +may be treated as "not a terminal"; anything else is a bug and must +propagate. +""" + +from __future__ import annotations + +import typing + +import pytest + +from progressbar import env + + +class RaisingFd: + def __init__(self, error: Exception) -> None: + self._error = error + + def isatty(self) -> bool: + raise self._error + + def write(self, value: str) -> None: # pragma: no cover - never called + pass + + +class TtyFd: + def __init__(self, tty: bool) -> None: + self._tty = tty + + def isatty(self) -> bool: + return self._tty + + def write(self, value: str) -> None: # pragma: no cover - never called + pass + + +@pytest.fixture +def clean_environment(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ('PROGRESSBAR_IS_TERMINAL', 'ANSICON', 'TERM'): + monkeypatch.delenv(name, raising=False) + + +@pytest.mark.parametrize( + 'error', [OSError('io'), ValueError('closed'), AttributeError('no tty')] +) +def test_ansi_detection_tolerates_stream_errors( + error: Exception, + clean_environment: None, +) -> None: + fd = typing.cast(typing.IO[str], RaisingFd(error)) + assert env.is_ansi_terminal(fd) is None + + +def test_ansi_detection_propagates_unexpected_errors( + clean_environment: None, +) -> None: + fd = typing.cast(typing.IO[str], RaisingFd(RuntimeError('bug'))) + with pytest.raises(RuntimeError): + env.is_ansi_terminal(fd) + + +@pytest.mark.parametrize( + 'error', [OSError('io'), ValueError('closed'), AttributeError('no tty')] +) +def test_is_terminal_tolerates_stream_errors( + error: Exception, + clean_environment: None, +) -> None: + fd = typing.cast(typing.IO[str], RaisingFd(error)) + assert env.is_terminal(fd) is False + + +def test_is_terminal_propagates_unexpected_errors( + clean_environment: None, +) -> None: + fd = typing.cast(typing.IO[str], RaisingFd(RuntimeError('bug'))) + with pytest.raises(RuntimeError): + env.is_terminal(fd) + + +def test_is_terminal_plain_tty_without_ansi(clean_environment: None) -> None: + # A tty that matches no ANSI heuristics is still a terminal: the ANSI + # probe returns None and the final isatty() fallback decides. + fd = typing.cast(typing.IO[str], TtyFd(True)) + assert env.is_terminal(fd) is True + + +def test_is_terminal_non_tty(clean_environment: None) -> None: + fd = typing.cast(typing.IO[str], TtyFd(False)) + assert env.is_terminal(fd) is False 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_fast_default.py b/tests/test_fast_default.py new file mode 100644 index 00000000..b9fc8700 --- /dev/null +++ b/tests/test_fast_default.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import gc +import io +import sys + +import progressbar + +# Alias (not a `from` import) so CodeQL doesn't flag `progressbar` as imported +# with both `import` and `import from`. +fast_module = progressbar.fast + + +class TTY(io.StringIO): + def isatty(self) -> bool: + return True + + def repaints(self) -> list[str]: + return [p for p in self.getvalue().split('\r') if p] + + +def test_fast_known_length_renders_and_completes(): + fd = TTY() + bar = fast_module.FastProgressBar(max_value=1000, fd=fd) + out = list(bar(range(1000))) + assert out == list(range(1000)) + assert bar.value == 1000 + assert bar.percentage == 100.0 + assert bar._finished + frames = fd.repaints() + assert frames, 'fast bar drew nothing' + # Close-to-default look: percentage, (n of max), a bar, Elapsed/ETA. + last = frames[-1] + assert '100%' in last + assert '(1000 of 1000)' in last + assert '|' in last # bar delimiters + assert 'Elapsed Time:' in last + + +def test_fast_elapsed_with_no_start_time(): + """Test _fast_elapsed returns 0 when start_time is None.""" + fd = TTY() + bar = fast_module.FastProgressBar(max_value=100, fd=fd) + assert bar._fast_elapsed() == 0.0 + + +def test_fast_format_line_with_eta_calculation(): + """Test ETA calculation path with done > 0 and elapsed > 0.""" + from datetime import datetime, timedelta + + fd = TTY() + bar = fast_module.FastProgressBar(max_value=100, fd=fd) + # Manually set start_time to the past to ensure elapsed > 0 + bar.start_time = datetime.now() - timedelta(seconds=2) + bar.value = 50 # Set done=50 to enable ETA calculation + line = bar._format_line() + # With done > 0 and elapsed > 0, ETA is computed instead of '--:--:--'. + assert 'ETA:' in line + assert 'ETA: --:--:--' not in line + + +def test_fast_spinner_frames_cycle(): + """The spinner is exactly four frames and cycles through all of them. + + Regression: the raw literal ``r'|/-\\'`` is 5 chars long (the escape is not + collapsed), so the intended four-frame cycle was fragile and length-coupled + to a hardcoded ``% 4``. + """ + from datetime import datetime, timedelta + + assert len(fast_module._SPINNER_FRAMES) == 4 + assert set(fast_module._SPINNER_FRAMES) == set('|/-\\') + + fd = TTY() + bar = fast_module.FastProgressBar( + max_value=progressbar.UnknownLength, fd=fd + ) + bar.start_time = datetime(2020, 1, 1) + seen = [] + for quarter in range(4): + # Freeze elapsed at exact quarter-seconds so int(elapsed * 4) walks + # 0, 1, 2, 3 and must surface each distinct frame. + bar.end_time = bar.start_time + timedelta(seconds=quarter / 4) + seen.append(bar._format_line().lstrip()[0]) + + assert seen == ['|', '/', '-', '\\'] + + +def test_fast_format_line_uses_native_hook(monkeypatch): + """The native `_format_fast_line` hook takes precedence when set.""" + + def stub(bar) -> str: + return 'NATIVE_HOOK_OUTPUT' + + monkeypatch.setattr(fast_module, '_format_fast_line', stub) + fd = TTY() + bar = fast_module.FastProgressBar(max_value=100, fd=fd) + assert bar._format_line() == 'NATIVE_HOOK_OUTPUT' + + +def test_fast_unknown_length_renders_count_and_elapsed(): + fd = TTY() + bar = fast_module.FastProgressBar( + max_value=progressbar.UnknownLength, fd=fd + ) + out = list(bar(iter(range(40)))) + assert out == list(range(40)) + assert bar.value == 39 + last = fd.repaints()[-1] + assert 'Elapsed Time:' in last + assert '40' in last # the count is shown + assert ' of ' not in last # no "(n of max)" when length unknown + + +def test_fast_prefix_suffix_in_line_not_widgets(): + fd = TTY() + bar = fast_module.FastProgressBar( + max_value=10, fd=fd, prefix='load ', suffix=' done' + ) + list(bar(range(10))) + assert bar.widgets == [] # prefix/suffix not injected as widgets + last = fd.repaints()[-1] + assert last.lstrip().startswith('load') + assert 'done' in last + + +def test_fast_empty_iterable(): + fd = TTY() + bar = fast_module.FastProgressBar(max_value=0, fd=fd) + assert list(bar([])) == [] + assert bar._finished + + +def test_fast_break_restores_streams(): + real_out = sys.stdout + fd = TTY() + bar = fast_module.FastProgressBar( + max_value=1000, fd=fd, redirect_stdout=True + ) + for i in bar(range(1000)): + if i == 5: + break + del bar + gc.collect() + assert sys.stdout is real_out + + +def test_fast_with_statement(): + fd = TTY() + with fast_module.FastProgressBar(max_value=10, fd=fd) as bar: + out = list(bar(range(10))) + assert out == list(range(10)) + assert bar._finished + + +def test_shortcut_dispatch(monkeypatch): + # Record which class the shortcut constructs for each input combination. + from progressbar import shortcuts + + calls = {'fast': 0, 'full': 0} + + class FastSpy(fast_module.FastProgressBar): + def __init__(self, *a, **k): + calls['fast'] += 1 + super().__init__(*a, **k) + + class FullSpy(progressbar.ProgressBar): + def __init__(self, *a, **k): + calls['full'] += 1 + super().__init__(*a, **k) + + monkeypatch.setattr(shortcuts.fast_module, 'FastProgressBar', FastSpy) + monkeypatch.setattr(shortcuts.bar, 'ProgressBar', FullSpy) + + # Default (no widgets, no fast flag) -> fast. + assert list(shortcuts.progressbar(range(3), fd=TTY())) == [0, 1, 2] + assert calls == {'fast': 1, 'full': 0} + + # Custom widgets -> full. + list( + shortcuts.progressbar( + range(3), fd=TTY(), widgets=[progressbar.Percentage()] + ) + ) + assert calls == {'fast': 1, 'full': 1} + + # fast=False -> full even with no widgets. + list(shortcuts.progressbar(range(3), fd=TTY(), fast=False)) + assert calls == {'fast': 1, 'full': 2} + + # Env override forces full. + monkeypatch.setenv('PROGRESSBAR_DISABLE_FASTPATH', '1') + list(shortcuts.progressbar(range(3), fd=TTY())) + assert calls == {'fast': 1, 'full': 3} + + # Dynamic variables force full (the fast formatter can't render them). + monkeypatch.delenv('PROGRESSBAR_DISABLE_FASTPATH', raising=False) + list(shortcuts.progressbar(range(3), fd=TTY(), variables={'x': 1})) + assert calls == {'fast': 1, 'full': 4} + + +def test_full_bar_injects_prefix_suffix_widgets(): + # The full ProgressBar (unlike the fast bar) injects prefix/suffix as + # FormatLabel widgets in start(); exercise that path directly. + fd = TTY() + bar_ = progressbar.ProgressBar( + max_value=10, fd=fd, prefix='pre ', suffix=' suf' + ) + list(bar_(range(10))) + assert bar_.widgets # widgets were built (not the fast empty list) + last = fd.repaints()[-1] + assert 'pre' in last + assert 'suf' in last + + +def test_import_progressbar_is_lazy(): + # A fresh interpreter: `import progressbar` must not eagerly pull heavy + # submodules; FastProgressBar still resolves lazily. + import subprocess + + check = ( + 'import sys, progressbar\n' + 'assert "progressbar.multi" not in sys.modules\n' + 'assert progressbar.FastProgressBar is not None\n' + 'print("ok")\n' + ) + out = subprocess.run( + [sys.executable, '-c', check], capture_output=True, text=True + ) + assert out.returncode == 0, out.stderr + assert 'ok' in out.stdout + + +def test_fast_path_does_not_import_widgets_or_colors(): + # Running the fast default end-to-end must not pull in the widgets module + # or the terminal colour tables (the heaviest imports). + import subprocess + + check = ( + 'import sys, progressbar\n' + 'list(progressbar.progressbar(range(10)))\n' + 'assert "progressbar.widgets" not in sys.modules\n' + 'assert "progressbar.terminal.colors" not in sys.modules\n' + 'print("ok")\n' + ) + out = subprocess.run( + [sys.executable, '-c', check], capture_output=True, text=True + ) + assert out.returncode == 0, out.stderr + assert 'ok' in out.stdout diff --git a/tests/test_fastpath.py b/tests/test_fastpath.py new file mode 100644 index 00000000..eedda601 --- /dev/null +++ b/tests/test_fastpath.py @@ -0,0 +1,620 @@ +# tests/test_fastpath.py +from __future__ import annotations + +import gc +import io +import itertools +import re +import sys +import typing + +import pytest + +import progressbar + +_ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*m') +_PERCENT = re.compile(r'(\d+)%') + + +def _drawn_percentages(repaints: list[str]) -> list[int]: + """Extract the integer percentage rendered in each repaint frame. + + The ``Percentage`` widget renders e.g. `` 4%|###...`` (no space before the + bar), so the ``%`` token is glued to the bar body; a regex is more robust + than whitespace tokenization. + """ + out: list[int] = [] + for frame in repaints: + match = _PERCENT.search(_ANSI_ESCAPE.sub('', frame)) + if match: + out.append(int(match.group(1))) + return out + + +def _assert_cadence_parity(gated: list[str], ungated: list[str]) -> None: + """Assert the gated run kept the ungated run's rate-limited cadence. + + This is the correct equivalence criterion (NOT byte-exact frames): the gate + may legitimately differ by a frame or two: its step is sized by time, + but it must not silently drop a large fraction of redraws the way the + original regression did (16 gated vs. 25 ungated buckets, a ~36% drop). The + checks below fail for such a gate while tolerating the benign +/-1 frame + wobble of the closed loop. + """ + g_count = len(gated) + u_count = len(ungated) + # 1) Rate-limited cadence parity: counts within a frame or two of each + # other. A ~36% drop (e.g. 21 vs 33) fails this by a wide margin. + assert abs(g_count - u_count) <= 2, ( + f'gated redraw count {g_count} diverged from ungated {u_count} ' + f'beyond rate-limited wobble' + ) + # Sanity: the slow loop really did redraw many distinct frames, so the + # comparison is meaningful (not "both drew nothing"). + assert len(set(gated)) > 10 + + g_pcts = _drawn_percentages(gated) + u_pcts = _drawn_percentages(ungated) + assert g_pcts, 'no percentage tokens found in gated frames' + # 2) Monotonic and reaches 100% at the end. + assert g_pcts == sorted(g_pcts), ( + f'gated percentages not monotonic: {g_pcts}' + ) + assert g_pcts[-1] == 100, f'gated did not reach 100%: {g_pcts[-1]}' + + # 3) No large gap: ignoring the final jump to 100% (the loop only covers + # part of the range, then finish() snaps to 100%), no consecutive + # percentages is farther apart than a small multiple of the ungated + # per-redraw window. A gate that drops whole stretches of the bar shows + # up as an oversized inner gap here. + inner_gaps = [g_pcts[i + 1] - g_pcts[i] for i in range(len(g_pcts) - 2)] + ungated_window = max( + (u_pcts[i + 1] - u_pcts[i] for i in range(len(u_pcts) - 2)), + default=1, + ) + if inner_gaps: + assert max(inner_gaps) <= 3 * max(ungated_window, 1), ( + f'gated skipped a stretch of the bar: max inner gap ' + f'{max(inner_gaps)} > 3x ungated window {ungated_window}' + ) + + +class RecordingTTY(io.StringIO): + """A fake terminal that records each repaint (\\r-delimited write).""" + + def isatty(self) -> bool: + return True + + def repaints(self) -> list[str]: + # Each redraw starts with '\r'; split and drop the empty head. + return [p for p in self.getvalue().split('\r') if p] + + +def run_iter(n: int, **kwargs: typing.Any) -> tuple[RecordingTTY, list[int]]: + fd = RecordingTTY() + seen = list(progressbar.progressbar(range(n), fd=fd, **kwargs)) + return fd, seen + + +def test_iterates_all_items_in_order(): + _, seen = run_iter(2000) + assert seen == list(range(2000)) + + +def test_value_is_live_during_iteration(): + fd = RecordingTTY() + bar = progressbar.ProgressBar(max_value=500, fd=fd) + last = -1 + for i in bar(range(500)): + # bar.value == i: value reflects items yielded so far (pre-increment), + # so at the start of the body for item i, value is i (not i+1). + assert bar.value == i, f'bar.value mismatch at i={i}: got {bar.value}' + # previous_value stays byte-identical to the pre-gate behavior on + # EVERY iteration (not just at redraws): the value before the current + # one (0 for the first item, set by start()'s forced draw). + expected_prev = i - 1 if i else 0 + assert bar.previous_value == expected_prev, ( + f'previous_value mismatch at i={i}: got {bar.previous_value}' + ) + last = i + assert last == 499 + + +def test_final_repaint_reaches_completion(): + fd, _ = run_iter(1000) + repaints = fd.repaints() + assert repaints, 'expected at least one repaint' + assert '100%' in repaints[-1] + + +def test_repaints_are_monotonic_in_percentage(): + fd, _ = run_iter(5000) + pcts = [] + for p in fd.repaints(): + # Repaints contain ANSI color codes; strip before tokenizing. + plain = _ANSI_ESCAPE.sub('', p) + for tok in plain.split(): + if tok.endswith('%'): + pcts.append(float(tok[:-1])) + break + assert pcts, 'expected at least one percentage token in repaints' + assert pcts == sorted(pcts), 'percentage went backwards' + assert pcts[0] >= 0 and pcts[-1] == 100.0 + + +def test_empty_iterable_finishes_cleanly(): + fd, seen = run_iter(0) + assert seen == [] + assert fd.getvalue() != '' # start+finish still draw + + +def test_single_item(): + fd, seen = run_iter(1) + assert seen == [0] + assert '100%' in fd.repaints()[-1] + + +def test_early_break_finishes_dirty(): + fd = RecordingTTY() + bar = progressbar.ProgressBar(max_value=1000, fd=fd) + for i in bar(range(1000)): + if i == 10: + break + del bar # trigger GeneratorExit cleanup path (issue #212) + gc.collect() + # A dirty finish must NOT jump the bar to 100%. + assert '100%' not in fd.repaints()[-1] + + +def test_exception_in_body_propagates_and_finishes(): + fd = RecordingTTY() + bar = progressbar.ProgressBar(max_value=1000, fd=fd) + + class BoomError(Exception): + pass + + with pytest.raises(BoomError): + for i in bar(range(1000)): + if i == 5: + raise BoomError + gc.collect() + assert fd.getvalue() != '' + + +def fixed_clock(monkeypatch, dt: float): + """Patch the timer used by bar.py to advance by `dt` per read.""" + bar_module = progressbar.bar + + counter = itertools.count() + + def fake_timer() -> float: + return next(counter) * dt + + monkeypatch.setattr(bar_module.timeit, 'default_timer', fake_timer) + + +def test_redraw_count_is_rate_limited(monkeypatch): + # ~1ms per timer read, 50ms min_poll_interval => far fewer redraws than N. + fixed_clock(monkeypatch, dt=0.001) + fd, _ = run_iter(20000) + n_repaints = len(fd.repaints()) + assert 1 < n_repaints < 2000, n_repaints # not one-per-iteration + + +def test_gate_state_initialized(): + bar = progressbar.ProgressBar(max_value=100) + assert bar._gate_enabled is True + assert bar._gate_step >= 1 + assert bar._next_update == 0 + assert bar._last_drawn_value is None + + +def _controlled_clock(monkeypatch) -> list[float]: + """Patch bar.py's timer to read one mutable value; return that list.""" + clock = [0.0] + monkeypatch.setattr( + progressbar.bar.timeit, 'default_timer', lambda: clock[0] + ) + return clock + + +def test_gate_calibrates_step_from_measured_rate(monkeypatch): + # The gate calibrates _gate_step from the value/time elapsed between two + # redraws (no separate _gate_last_* state needed). UnknownLength makes any + # value advance redraw (rate-limited), so the measurement is deterministic. + clock = _controlled_clock(monkeypatch) + bar = progressbar.ProgressBar( + max_value=progressbar.UnknownLength, fd=RecordingTTY() + ) + bar.min_poll_interval = 0.05 + bar.start() # forced draw at t=0; no prior sample, so step stays 1 + assert bar._gate_step == 1 + clock[0] = 0.10 # 0.10 s later + bar.update(1000) # redraw: 1000 iters over 0.10 s + # step = int((1000 - 0) * min_poll_interval / interval) = 1000*0.05/0.10 + assert bar._gate_step == 500 + assert bar._next_update == 1000 + 500 + + +def test_gate_backs_off_when_calibrated_and_no_redraw(monkeypatch): + clock = _controlled_clock(monkeypatch) + bar = progressbar.ProgressBar( + max_value=progressbar.UnknownLength, fd=RecordingTTY() + ) + bar.min_poll_interval = 0.05 + bar.start() + clock[0] = 0.10 + bar.update(1000) # calibrate: step=500, _next_update=1500 + step = bar._gate_step + assert step == 500 + # Time frozen: an update past the threshold finds delta == 0 (no redraw), + # so the gate backs off (doubles the step) instead of re-checking often. + bar.update(1500) + assert bar._gate_step == step * 2 + assert bar._next_update == 1500 + step * 2 + + +def test_previous_value_tracks_last_redraw(monkeypatch): + fixed_clock(monkeypatch, dt=0.001) + bar = progressbar.ProgressBar(max_value=10000, fd=RecordingTTY()) + bar.start() + drawn: list[int] = [] + real_parents = bar._update_parents + + def spy(value): + real_parents(value) + drawn.append(bar.value) + + bar._update_parents = spy + for i in range(1, 10001): + bar.update(i) + bar.finish() + # previous_value must equal one of the actually-drawn values, not i-1. + assert bar.previous_value in drawn + + +def test_last_drawn_value_pinned_on_skipped_update(monkeypatch): + """The gate's pixel reference advances only when a redraw happens. + + `_last_drawn_value` (the private pixel reference used by `_needs_update`) + must stay pinned to the value at the last actual draw, even as later + `update()` calls advance `self.value` without redrawing. The public + `previous_value` keeps its original meaning: the value before the most + recent `update()` call. + + After a draw at value=3 (from 0) and two rate-limited skips at 4 then 5: + _last_drawn_value == 3 (pinned to the drawn value, for pixel check) + previous_value == 4 (value before the update(5) call) + """ + bar_module = progressbar.bar + + # Freeze-then-advance clock: start at 0, jump to 1.0 so update(3) draws, + # then keep it at 1.0 so subsequent updates are rate-limited (skipped). + _time: list[float] = [0.0] + + def timer() -> float: + return _time[0] + + monkeypatch.setattr(bar_module.timeit, 'default_timer', timer) + + bar = progressbar.ProgressBar(max_value=100, fd=RecordingTTY()) + bar.start() # _last_update_timer = 0.0 + + # Advance time far past min_poll_interval (0.05 s) => update(3) draws. + _time[0] = 1.0 + bar.update(3) + assert bar._last_drawn_value == 3 # a redraw happened at value 3 + + # Time frozen at 1.0: delta == 0 => _needs_update() returns False, so the + # next updates advance self.value but do not redraw. + bar.update(4) + bar.update(5) + + # Pixel reference stays at the last drawn value; public previous_value + # tracks the value before the most recent update() call. + assert bar._last_drawn_value == 3, ( + f'_last_drawn_value should stay at last-drawn (3), ' + f'got {bar._last_drawn_value!r}' + ) + assert bar.value == 5 # liveness preserved on the manual path + assert bar.previous_value == 4 # value before update(5) + + +def test_gate_disabled_skips_calibration(): + """When _gate_enabled is False the gate is never (re)calibrated.""" + bar = progressbar.ProgressBar(max_value=100, fd=RecordingTTY()) + bar.start() + bar._gate_enabled = False + initial_next = bar._next_update + bar.update(50) + # Neither the calibration nor the back-off branch runs: _next_update is + # left untouched while the fast path is disabled. + assert bar._next_update == initial_next + + +@pytest.mark.no_freezegun +def test_manual_update_skips_clock_when_gated(monkeypatch): + bar_module = progressbar.bar + + reads: dict[str, int] = {'n': 0} + real = bar_module.timeit.default_timer + + def counting() -> float: + reads['n'] += 1 + return real() + + bar = progressbar.ProgressBar(max_value=10**7, fd=RecordingTTY()) + bar.start() + monkeypatch.setattr(bar_module.timeit, 'default_timer', counting) + before = reads['n'] + for i in range(1, 1_000_001): + bar.update(i) + reads_during = reads['n'] - before + bar.finish() + # Far fewer clock reads than updates (gate skips the common path). + assert reads_during < 100_000, reads_during + + +def _iter_clock(monkeypatch, dt: float) -> dict[str, int]: + """Patch the timer so its value depends on a shared loop ITERATION. + + Unlike ``fixed_clock`` (which ties time to the *number of reads*), this + makes the clock return ``state['i'] * dt`` regardless of how many times + it is read. The gated and ungated bars read the clock a different number + of times, so a per-read clock would make them diverge for the wrong + reason. Tying time to the iteration index keeps both runs seeing the + exact same wall time at every iteration. + """ + bar_module = progressbar.bar + + state: dict[str, int] = {'i': 0} + monkeypatch.setattr( + bar_module.timeit, + 'default_timer', + lambda: state['i'] * dt, + ) + return state + + +def _drawn_frames( + disable_gate: bool, + monkeypatch, + *, + widgets: list | None = None, + dt: float = 0.06, + n: int = 4000, + maxv: int = 10_000, +) -> list[str]: + state = _iter_clock(monkeypatch, dt) + fd = RecordingTTY() + if widgets is None: + # poll_interval stays None for this widget set, which is the case + # that exposed the uncalibrated back-off bug. + widgets = [progressbar.Percentage(), progressbar.Bar()] + bar = progressbar.ProgressBar(max_value=maxv, fd=fd, widgets=widgets) + bar.start() + if disable_gate: + bar._gate_enabled = False + for i in range(1, n + 1): + state['i'] = i # advance wall time per ITERATION + bar.update(i) + bar.finish() + return fd.repaints() + + +def test_gated_matches_ungated_drawn_frames(monkeypatch): + """The gate must keep the ungated rate-limited cadence (manual path). + + For a ``poll_interval is None`` bar over a slow loop (``dt`` >= + ``min_poll_interval`` so a redraw is due at each item), a gated bar must + redraw at the same rate-limited cadence as an identical bar with the gate + disabled. This is the reviewer's repro of the regression where the gate + dropped ~36% of the buckets the baseline rendered. + + The criterion is rate-limited cadence parity, NOT byte-exact frames: the + closed-loop gate sizes its step by time, so a +/-1 frame wobble is benign + and expected. ``_assert_cadence_parity`` tolerates that wobble while still + failing for a gate that drops a large fraction of redraws. + """ + with monkeypatch.context() as m: + gated = _drawn_frames(False, m) + with monkeypatch.context() as m: + ungated = _drawn_frames(True, m) + + _assert_cadence_parity(gated, ungated) + + +def _drawn_frames_iter( + disable_gate: bool, + monkeypatch, + *, + widgets: list | None = None, + dt: float = 0.06, + n: int = 4000, + maxv: int = 10_000, +) -> list[str]: + """Drive the bar through its ITERATOR path and record drawn frames. + + Mirrors ``_drawn_frames`` but uses ``ProgressBar.__iter__`` (the iterator + fast path) instead of manual ``update()`` calls. ``__iter__`` skips + ``start()`` when ``start_time`` is already set, so we call ``start()`` + explicitly first (which resets the gate via ``init()``), then flip + ``_gate_enabled`` to choose gated vs. ungated. Both runs share the same + iteration-driven clock so they observe identical wall time per iteration. + """ + state = _iter_clock(monkeypatch, dt) + fd = RecordingTTY() + if widgets is None: + # poll_interval stays None for this widget set, which is the case + # that exposed the uncalibrated back-off bug in the iterator path. + widgets = [progressbar.Percentage(), progressbar.Bar()] + bar = progressbar.ProgressBar(max_value=maxv, fd=fd, widgets=widgets) + bar.start() # resets _gate_enabled via init(); primes start_time + if disable_gate: + bar._gate_enabled = False + + class _IterClockRange: + """An iterable that advances the shared clock once per item. + + Returning a fresh iterator each time keeps ``bar.value`` and the + iteration index aligned regardless of how many times the clock is read. + """ + + def __len__(self) -> int: + return n + + def __iter__(self): + for i in range(n): + state['i'] = i # advance wall time per ITERATION + yield i + + # __iter__ does not call start() again because start_time is already set. + for _ in bar(_IterClockRange()): + pass + return fd.repaints() + + +def test_iterator_gated_matches_ungated_drawn_frames(monkeypatch): + """The ITERATOR-path gate must keep the ungated rate-limited cadence. + + Reviewer's repro of the regression in ``__iter__``: for a + ``poll_interval is None`` bar over a slow, iteration-driven clock + (``dt`` >= ``min_poll_interval`` so a redraw is due at each item), the + iterator path's inline gate skipped ``update()`` based on ``_next_update`` + with a bogus pre-measurement step, leaping over whole buckets and dropping + redraws the ungated bar rendered. + + With the fix (``_gate_step`` starts at 1 so ``__iter__`` calls ``update()`` + every iteration until a real measurement grows it), the gated + iterator must redraw at the same rate-limited cadence as an identical bar + driven through the same iterator with the gate disabled. As in the manual + path the criterion is cadence parity, not byte-exact frames. + """ + with monkeypatch.context() as m: + gated = _drawn_frames_iter(False, m) + with monkeypatch.context() as m: + ungated = _drawn_frames_iter(True, m) + + _assert_cadence_parity(gated, ungated) + + +# NOTE: A default-widget bar (poll_interval is set by the Timer/animation +# widgets) intentionally does NOT get a byte-exact equivalence test. Its +# redraws are time-driven, not value-driven, so matching the ungated frame +# sequence would require the gate to read the clock on every call - which is +# precisely the read the gate exists to skip. The correctness obligation only +# binds the value-driven (poll_interval is None) case above, which is also the +# case that exposed the uncalibrated back-off bug. + + +def test_next_direct_exhaustion_calls_finish(): + """Direct next(bar) still finishes the bar on StopIteration.""" + fd = RecordingTTY() + bar = progressbar.ProgressBar(max_value=2, fd=fd) + bar(range(2)) + bar.start() + assert next(bar) == 0 + assert next(bar) == 1 + with pytest.raises(StopIteration): + next(bar) # exhausts iterable, calls finish() + assert '100%' in fd.repaints()[-1] + + +def test_shortcut_has_single_generator_layer(): + import types + + gen = progressbar.progressbar(range(3), fd=RecordingTTY()) + assert isinstance(gen, types.GeneratorType) + # It is the bar's own iterator generator, not a wrapper: compare the + # generator's code object to ProgressBar._iter_python (the pure-Python + # path `__iter__` dispatches to; robust across versions). The autouse + # `disable_native_accelerator` fixture forces this path here. + assert gen.gi_code is progressbar.ProgressBar._iter_python.__code__ + + +def test_env_disables_fastpath(monkeypatch): + monkeypatch.setenv('PROGRESSBAR_DISABLE_FASTPATH', '1') + bar = progressbar.ProgressBar(max_value=100, fd=RecordingTTY()) + bar.start() + assert bar._gate_enabled is False + + +def test_zero_min_poll_interval_disables_gate(): + # Build a bar and force min_poll_interval to zero on the *instance* (not + # the class) before calling start(), so the class version-tag is + # untouched and CPython's adaptive specialiser for __iter__ is not + # disturbed. + bar = progressbar.ProgressBar(max_value=100, fd=RecordingTTY()) + bar.min_poll_interval = 0.0 # instance-level override, zero rate-limit + bar.start() + # With no rate limit the user wants every update considered. + assert bar._gate_enabled is False + + +@pytest.mark.no_freezegun +@pytest.mark.skipif( + sys.gettrace() is not None, + reason='coverage tracing inflates per-iteration cost; benchmark skipped', +) +def test_iterator_overhead_is_low(): + import timeit as _t + + # Use the REAL clock (no fixed_clock): under a frozen clock (dt == 0) the + # corrected gate never calibrates, so update() runs every iteration and the + # measurement no longer reflects the gated fast path. A real, advancing + # `perf_counter` lets the gate calibrate and skip as it does in production. + fd = RecordingTTY() + n = 200_000 + t = min( + _t.timeit( + lambda: [None for _ in progressbar.progressbar(range(n), fd=fd)], + number=1, + ) + for _ in range(3) + ) + ns = t / n * 1e9 + # Generous smoke gate only; the authoritative per-iteration budget is + # enforced in tests/test_perf_budget.py (Task 8). + assert ns < 200, f'{ns:.1f} ns/iter (real clock)' + + +def test_no_color_fast_path_and_ansi(): + # Render-cost optimization adds a no-ESC fast path to no_color/len_color. + # It must be identical to the regex path for both plain and ANSI input. + utils = progressbar.utils + + # Fast path (no ESC byte): returned unchanged, str and bytes. + assert utils.no_color('plain text') == 'plain text' + assert utils.no_color(b'plain bytes') == b'plain bytes' + assert utils.len_color('plain') == 5 + # Regex path (ANSI present): escape sequences stripped, str and bytes. + assert utils.no_color('\x1b[31mred\x1b[0m') == 'red' + assert utils.no_color(b'\x1b[31mred\x1b[0m') == b'red' + assert utils.len_color('\x1b[1mbold\x1b[0m') == 4 + + +def test_no_color_patterns_are_precompiled(): + # F5: the str and bytes ANSI patterns are compiled once at module import, + # not rebuilt on every no_color() call (which runs per widget per redraw). + utils = progressbar.utils + assert isinstance(utils._ANSI_COLOR_RE, re.Pattern) + assert isinstance(utils._ANSI_COLOR_RE_BYTES, re.Pattern) + # Both variants must still strip ANSI correctly. + assert utils.no_color('\x1b[32mgreen\x1b[0m') == 'green' + assert utils.no_color(b'\x1b[32mgreen\x1b[0m') == b'green' + + +def test_render_output_stable(monkeypatch): + # Guard the default-widget render path against the render-cost + # optimization changing appearance: the final repaint must reach 100%. + fixed_clock(monkeypatch, dt=10.0) # force a redraw on every forced update + fd = RecordingTTY() + bar = progressbar.ProgressBar(max_value=100, fd=fd) + bar.start() + for i in range(1, 101): + bar.update(i, force=True) + bar.finish() + repaints = fd.repaints() + assert repaints + last = _ANSI_ESCAPE.sub('', repaints[-1]) + assert last.strip().startswith('100%') 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_init_exports.py b/tests/test_init_exports.py new file mode 100644 index 00000000..0f090960 --- /dev/null +++ b/tests/test_init_exports.py @@ -0,0 +1,63 @@ +"""Guard the three hand-synced export lists in ``progressbar/__init__.py``. + +``_NAME_TO_MODULE`` is the single source of truth for the lazily re-exported +public names. ``__all__`` and the ``TYPE_CHECKING`` import block must stay in +sync with it; these tests fail loudly if any of the three drift apart. +""" + +from __future__ import annotations + +import ast +import pathlib + +import progressbar + +# Alias (not a `from` import) so CodeQL doesn't flag `progressbar` as imported +# with both `import` and `import from`. +_NAME_TO_MODULE = progressbar._NAME_TO_MODULE + +#: Dunders that are eagerly imported (not part of ``_NAME_TO_MODULE``) but are +#: still part of the public ``__all__``. +_EAGER_DUNDERS: frozenset[str] = frozenset({'__author__', '__version__'}) + + +def test_every_mapping_name_resolves() -> None: + # Each lazily re-exported name must be reachable via attribute access + # (which drives ``__getattr__``'s import machinery). + for name in _NAME_TO_MODULE: + assert getattr(progressbar, name) is not None, name + + +def test_all_matches_mapping_plus_dunders() -> None: + # ``__all__`` must contain exactly the mapping names plus the eager + # dunders. The concrete ordering is delegated to ruff's RUF022, so this + # compares contents rather than the exact list order. + assert set(progressbar.__all__) == set(_NAME_TO_MODULE) | _EAGER_DUNDERS + + +def test_all_has_no_duplicates() -> None: + assert len(progressbar.__all__) == len(set(progressbar.__all__)) + + +def test_type_checking_block_imports_exactly_the_mapping() -> None: + source: str = pathlib.Path(progressbar.__file__).read_text() + tree: ast.Module = ast.parse(source) + + imported: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.If): + continue + + test = node.test + is_type_checking = ( + isinstance(test, ast.Attribute) and test.attr == 'TYPE_CHECKING' + ) or (isinstance(test, ast.Name) and test.id == 'TYPE_CHECKING') + if not is_type_checking: + continue + + for stmt in node.body: + if isinstance(stmt, ast.ImportFrom): + for alias in stmt.names: + imported.add(alias.asname or alias.name) + + assert imported == set(_NAME_TO_MODULE) 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..9747feac --- /dev/null +++ b/tests/test_job_status.py @@ -0,0 +1,87 @@ +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) + + +def _make_status_bar() -> tuple: + bar = progressbar.ProgressBar( + widgets=[progressbar.widgets.JobStatusBar('status')], + variables={'status': None}, + max_value=100, + fd=io.StringIO(), + term_width=60, + ) + bar.start() + data = bar.data() + data['variables'] = {'status': True} + return bar, data + + +def test_job_markers_do_not_interleave_across_bars() -> None: + # Regression: F2 - a single JobStatusBar reused by two ProgressBars kept + # its marker history on the widget itself, so markers from one bar bled + # into the other. State must live in ``progress.extra`` per bar. + widget = progressbar.widgets.JobStatusBar('status') + + bar_a, data_a = _make_status_bar() + bar_b, data_b = _make_status_bar() + + width = 20 + for _ in range(3): + widget(bar_a, data_a, width=width) + widget(bar_b, data_b, width=width) + + markers_a = widget.get_job_markers(bar_a) + markers_b = widget.get_job_markers(bar_b) + + assert len(markers_a) == 3 + assert len(markers_b) == 1 + assert markers_a is not markers_b + + bar_a.finish(dirty=True) + bar_b.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/misc.py b/tests/test_misc.py similarity index 92% rename from tests/misc.py rename to tests/test_misc.py index c07002f7..b0725afe 100644 --- a/tests/misc.py +++ b/tests/test_misc.py @@ -1,7 +1,7 @@ from progressbar import __about__ -def test_about(): +def test_about() -> None: assert __about__.__title__ assert __about__.__package_name__ assert __about__.__author__ diff --git a/tests/test_monitor_progress.py b/tests/test_monitor_progress.py new file mode 100644 index 00000000..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..9b6d66fd --- /dev/null +++ b/tests/test_multibar.py @@ -0,0 +1,579 @@ +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_started_flag_not_observable_before_widgets(monkeypatch) -> None: + """Regression: ``_started`` must not flip True before widgets are built. + + ``MultiBar.render()`` (potentially from a background thread) reads + ``bar.started()`` and then ``_label_bar`` asserts ``bar.widgets``. If + ``start()`` sets ``_started`` before populating ``default_widgets()`` there + is a window where a concurrent reader observes ``started() is True`` with + an empty ``widgets`` list and crashes on that assertion. Reproduced + deterministically by capturing the widget list at the exact ``_started`` + flip. + """ + import progressbar.bar as bar_module + + original_start = bar_module.ProgressBarMixinBase.start + observed: dict[str, bool] = {} + + def recording_start(self, **kwargs): + result = original_start(self, **kwargs) + # `_started` has just flipped True here; capture whether the widget + # list is already populated at this exact moment. + observed['widgets_at_flip'] = bool(self.widgets) + observed['started_at_flip'] = self.started() + # update() re-enters start() while start_time is None, so a + # concurrent update(force=True) would double-run the start path. + observed['start_time_at_flip'] = self.start_time is not None + return result + + monkeypatch.setattr( + bar_module.ProgressBarMixinBase, 'start', recording_start + ) + + bar = progressbar.ProgressBar(max_value=N, fd=io.StringIO()) + bar.start() + + assert observed.get('started_at_flip') is True + assert observed.get('widgets_at_flip') is True, ( + 'widgets must be populated before started() can observe _started' + ) + assert observed.get('start_time_at_flip') is True, ( + 'start_time must be set before started() can observe _started' + ) + + +def test_multibar_print() -> None: + bars = 5 + n = 10 + + def print_sometimes(bar, probability, seed): + # A per-thread seeded RNG keeps the interleaving jittery but fully + # reproducible: a thread only ever touches its own RNG, so thread + # scheduling cannot reorder its draws. Combined with the never/always + # threads below this makes the print-guard branch coverage + # deterministic instead of flakily depending on the global RNG. + rng = random.Random(seed) + for i in bar(range(n)): + # Sleep a small, deterministic fraction of a second + time.sleep(rng.random() * 0.01) + + # print messages at random intervals to show how extra output works + if rng.random() < probability: + bar.print('random message for bar', bar, i) + + with progressbar.MultiBar() as multibar: + seed = 0 + threads: list[threading.Thread] = [] + 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 (0.0), + # sometimes (0.5) and always (1.0): the 0.0 and 1.0 threads + # deterministically exercise both sides of the print guard on + # every run, independent of the seeded middle thread. + for probability in (0.0, 0.5, 1.0): + thread = threading.Thread( + target=print_sometimes, args=(bar, probability, seed) + ) + thread.start() + threads.append(thread) + seed += 1 + + for i in range(5): + multibar.print(f'{i}', flush=False) + + # Join the workers before leaving the context so none outlives the + # multibar (a stray thread writing to a torn-down fd raised + # "I/O operation on closed file") and every print branch has run. + for thread in threads: + thread.join() + + # 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_render_writes_started_bar_text() -> None: + fd = io.StringIO() + multibar = progressbar.MultiBar( + fd=fd, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + total=3, + term_width=64, + ) + build = multibar['build'] + test = multibar['test'] + multibar.render(force=True, flush=True) + fd.seek(0) + fd.truncate(0) + + build.update(1, force=True) + test.update(1, force=True) + multibar.render(force=True, flush=True) + + output = fd.getvalue() + assert 'build' in output + assert 'test' in output + assert '(1 of 3)' in output + + +def test_multibar_wraps_pre_labeled_bar_stream() -> None: + fd = io.StringIO() + multibar = progressbar.MultiBar( + fd=fd, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + total=3, + term_width=64, + ) + bar = progressbar.ProgressBar(max_value=3) + bar.label = 'build' + multibar['build'] = bar + + bar.update(1, force=True) + multibar.render(force=True, flush=True) + + output = fd.getvalue() + assert 'build' in output + assert '(1 of 3)' in output + + +def test_multibar_constructor_wraps_external_bar_stream() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar(max_value=3) + multibar = progressbar.MultiBar( + [('build', bar)], + fd=fd, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + total=3, + term_width=64, + ) + + bar.update(1, force=True) + multibar.render(force=True, flush=True) + + output = fd.getvalue() + assert 'build' in output + assert '(1 of 3)' in output + + +def test_multibar_constructor_accepts_mapping() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar(max_value=3) + multibar = progressbar.MultiBar( + {'build': bar}, + fd=fd, + initial_format=None, + finished_format=None, + remove_finished=None, + sort_reverse=False, + total=3, + term_width=64, + ) + + bar.update(1, force=True) + multibar.render(force=True, flush=True) + + output = fd.getvalue() + assert 'build' in output + assert '(1 of 3)' in output + + +def test_multibar_finished_format() -> None: + multibar = progressbar.MultiBar( + finished_format='Finished {label}', show_finished=True + ) + 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_join_timeout_abandons_unfinished_bar() -> None: + # A never-finishing bar must not hang a clean context-manager exit when + # join_timeout is set; the unfinished bar is abandoned once it elapses. + multibar = progressbar.MultiBar(fd=io.StringIO(), join_timeout=0.1) + bar = progressbar.ProgressBar(max_value=10) + multibar['stuck'] = bar + # Fully start the bar before the render thread exists: started() flips + # true before the widgets are populated, so a render tick during a + # concurrent bar.start() can crash the render thread on the + # empty-widgets assert -- join() then succeeds on a dead thread and the + # timeout path this test exists to exercise is never taken. + bar.start() + bar.update(5) # never reaches max_value / finish() + + def exit_context() -> None: + with multibar: + pass + + thread = threading.Thread(target=exit_context, daemon=True) + thread.start() + thread.join(timeout=5) + exited = not thread.is_alive() + + # join_timeout leaves the render thread (a daemon) running so the + # program can exit; stop it explicitly so it cannot leak into and + # pollute later tests in the same process. + multibar.stop() + + assert exited, 'clean context exit blocked despite join_timeout' + + +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_native_accelerator.py b/tests/test_native_accelerator.py new file mode 100644 index 00000000..d88029ce --- /dev/null +++ b/tests/test_native_accelerator.py @@ -0,0 +1,286 @@ +# tests/test_native_accelerator.py +"""Tests for the optional native (Cython) iterator accelerator. + +Two groups: + +* Integration-coverage tests that exercise ``ProgressBar.__iter__`` dispatch + and the ``_fast_*`` protocol hooks **without** needing the compiled + ``speedups`` package (using a fake iterator / direct calls), so they run — + and keep ``bar.py`` at 100% coverage — in CI where ``speedups`` is absent. +* End-to-end equivalence tests marked ``@requires_speedups`` that drive the + real ``speedups.progressbar.FastBarIterator``; they run wherever it is + installed (dev/bench env) and are skipped otherwise. + +The conftest ``disable_native_accelerator`` autouse fixture forces the +pure-Python path for the rest of the suite; here we restore the real iterator +explicitly where needed. +""" + +from __future__ import annotations + +import gc +import io +import re +import sys + +import pytest + +import progressbar + +# Alias (not a `from` import) so CodeQL doesn't flag `progressbar` as imported +# with both `import` and `import from`. +bar_module = progressbar.bar + +# Captured at import, before the autouse fixture nulls it for each test. +_REAL_FAST = bar_module._FastBarIterator +HAS_SPEEDUPS = _REAL_FAST is not None +requires_speedups = pytest.mark.skipif( + not HAS_SPEEDUPS, + reason='native accelerator (speedups package) not installed', +) + +_PERCENT = re.compile(r'(\d+)%') +_ANSI = re.compile(r'\x1b\[[0-9;]*m') + + +class TTY(io.StringIO): + def isatty(self) -> bool: + return True + + +class RecordingTTY(io.StringIO): + def isatty(self) -> bool: + return True + + def repaints(self) -> list[str]: + return [p for p in self.getvalue().split('\r') if p] + + +def _percentages(frames: list[str]) -> list[int]: + out: list[int] = [] + for frame in frames: + match = _PERCENT.search(_ANSI.sub('', frame)) + if match: + out.append(int(match.group(1))) + return out + + +class _FakeFast: + """Stand-in for FastBarIterator: records construction, yields nothing. + + Lets the native dispatch branch be covered without the compiled package. + """ + + def __init__(self, bar, iterable): + self.bar = bar + self.iterable = iterable + + def __iter__(self): + return self + + def __next__(self): + raise StopIteration + + +# --- dispatch coverage (no compiled speedups required) -------------------- + + +def test_iter_uses_native_when_available(monkeypatch): + monkeypatch.setattr(bar_module, '_FastBarIterator', _FakeFast) + bar = progressbar.ProgressBar(max_value=10, fd=TTY()) + iterable = range(10) + it = iter(bar(iterable)) + assert isinstance(it, _FakeFast) + assert it.bar is bar + assert it.iterable is bar._iterable + + +def test_iter_falls_back_when_native_absent(monkeypatch): + monkeypatch.setattr(bar_module, '_FastBarIterator', None) + bar = progressbar.ProgressBar(max_value=10, fd=TTY()) + it = iter(bar(range(10))) + assert not isinstance(it, _FakeFast) + assert list(it) == list(range(10)) + + +def test_iter_falls_back_without_iterable(monkeypatch): + # Native needs an iterable; iterating a bar without one must not use it. + monkeypatch.setattr(bar_module, '_FastBarIterator', _FakeFast) + bar = progressbar.ProgressBar(max_value=10, fd=TTY()) + it = iter(bar) + assert not isinstance(it, _FakeFast) + + +def test_iter_falls_back_when_env_disabled(monkeypatch): + monkeypatch.setattr(bar_module, '_FastBarIterator', _FakeFast) + monkeypatch.setenv('PROGRESSBAR_DISABLE_FASTPATH', '1') + bar = progressbar.ProgressBar(max_value=10, fd=TTY()) + it = iter(bar(range(10))) + assert not isinstance(it, _FakeFast) + assert list(it) == list(range(10)) + + +# --- protocol hook unit coverage (no compiled speedups required) ---------- + + +def test_fast_begin_starts_once(): + bar = progressbar.ProgressBar(max_value=10, fd=TTY()) + assert bar.start_time is None + bar._fast_begin() + assert bar.start_time is not None + started = bar.start_time + bar._fast_begin() # already started: no-op + assert bar.start_time is started + + +def test_fast_tick_updates_value(): + bar = progressbar.ProgressBar(max_value=100, fd=TTY()) + bar._fast_begin() + bar._fast_tick(50) + assert bar.value == 50 + + +def test_fast_end_finishes_at_100(): + bar = progressbar.ProgressBar(max_value=10, fd=TTY()) + bar._fast_begin() + bar._fast_end() + assert bar._finished + assert bar.value == bar.max_value + + +def test_fast_end_dirty_keeps_partial_value(): + bar = progressbar.ProgressBar(max_value=10, fd=TTY()) + bar._fast_begin() + bar._fast_tick(3) + bar._fast_end_dirty() + assert bar._finished + assert bar.value == 3 # not snapped to max_value + + +# --- end-to-end with the real compiled accelerator ------------------------ + + +@pytest.fixture +def native(monkeypatch): + """Restore the real FastBarIterator for a single test.""" + monkeypatch.setattr(bar_module, '_FastBarIterator', _REAL_FAST) + return _REAL_FAST + + +@requires_speedups +def test_native_iterator_type(native): + bar = progressbar.ProgressBar(max_value=10, fd=TTY()) + it = iter(bar(range(10))) + assert type(it) is _REAL_FAST + + +@requires_speedups +def test_native_yields_all_items_and_final_value(native): + bar = progressbar.ProgressBar(max_value=100, fd=RecordingTTY()) + out = list(bar(range(100))) + assert out == list(range(100)) + assert bar.value == 100 + assert bar.percentage == 100.0 + assert bar._finished + + +@requires_speedups +def test_native_renders_and_finishes_at_100(native): + fd = RecordingTTY() + list(progressbar.progressbar(range(500), fd=fd)) + frames = fd.repaints() + assert frames, 'native path drew nothing' + pcts = _percentages(frames) + assert pcts == sorted(pcts), f'percentages not monotonic: {pcts}' + assert pcts[-1] == 100 + + +@requires_speedups +def test_native_matches_fallback_items(native, monkeypatch): + # Native run. + native_items = list(progressbar.progressbar(range(250), fd=RecordingTTY())) + # Fallback run (force pure-Python). + monkeypatch.setattr(bar_module, '_FastBarIterator', None) + fallback_items = list( + progressbar.progressbar(range(250), fd=RecordingTTY()) + ) + assert native_items == fallback_items == list(range(250)) + + +@requires_speedups +def test_native_generator_input(native): + def gen(): + yield from range(30) + + bar = progressbar.ProgressBar(max_value=30, fd=RecordingTTY()) + assert list(bar(gen())) == list(range(30)) + assert bar.value == 30 + + +@requires_speedups +def test_native_unknown_length(native): + bar = progressbar.ProgressBar( + max_value=progressbar.UnknownLength, fd=RecordingTTY() + ) + out = list(bar(iter(range(40)))) + assert out == list(range(40)) + assert bar.value == 39 + assert bar._finished + + +@requires_speedups +def test_native_empty_iterable(native): + bar = progressbar.ProgressBar(max_value=0, fd=RecordingTTY()) + assert list(bar([])) == [] + assert bar._finished + + +@requires_speedups +def test_native_with_statement(native): + fd = RecordingTTY() + with progressbar.ProgressBar(max_value=10, fd=fd) as bar: + out = list(bar(range(10))) + assert out == list(range(10)) + assert bar._finished + + +@requires_speedups +def test_native_overshoot_clamps(native): + # max_error=False: iterating past max_value clamps instead of raising. + bar = progressbar.ProgressBar( + max_value=5, fd=RecordingTTY(), max_error=False + ) + out = list(bar(range(20))) + assert out == list(range(20)) # every item still yielded + assert bar.value == 5 # clamped to max at finish + + +@requires_speedups +def test_native_break_restores_streams(native): + # Issue #212: breaking out of the loop must restore redirected streams, + # which the cdef iterator does via __dealloc__ (no GeneratorExit hook). + real_out, real_err = sys.stdout, sys.stderr + fd = RecordingTTY() + bar = progressbar.ProgressBar(max_value=1000, fd=fd, redirect_stdout=True) + for i in bar(range(1000)): + assert sys.stdout is not real_out # redirected while iterating + if i == 5: + break + del bar + gc.collect() + assert sys.stdout is real_out + assert sys.stderr is real_err + + +@requires_speedups +def test_native_exception_restores_streams(native): + real_out = sys.stdout + fd = RecordingTTY() + bar = progressbar.ProgressBar(max_value=1000, fd=fd, redirect_stdout=True) + with pytest.raises(ValueError): + for i in bar(range(1000)): + if i == 5: + raise ValueError('boom') + del bar + gc.collect() + assert sys.stdout is real_out diff --git a/tests/test_needs_update.py b/tests/test_needs_update.py new file mode 100644 index 00000000..5577d86a --- /dev/null +++ b/tests/test_needs_update.py @@ -0,0 +1,93 @@ +"""`_needs_update` guard behavior. + +The width-threshold computation used to run inside +``contextlib.suppress(Exception)``, so any unexpected failure silently +disabled redraws. The known-legitimate incomplete states (no value drawn +yet, no terminal width, zero max value) must still return False exactly +as before, while genuinely unexpected errors now propagate. +""" + +from __future__ import annotations + +import io +import typing + +import pytest + +import progressbar +import progressbar.utils + + +@pytest.fixture(autouse=True) +def _deregister_started_bars() -> typing.Iterator[None]: + # start() registers each bar as a global capture listener (so writes to + # the wrapped stream redraw it). These tests deliberately drive bars into + # invalid states -- e.g. a non-numeric term_width -- and never finish() + # them. Deregister whatever this test started so a poisoned bar cannot be + # update()d when a *later* test writes a newline to the captured stream. + streams = progressbar.utils.streams + before = set(streams.listeners) + yield + for bar in list(streams.listeners - before): + streams.stop_capturing(bar) + + +def _bar(**kwargs: typing.Any) -> progressbar.ProgressBar: + bar = progressbar.ProgressBar( + fd=io.StringIO(), + max_value=100, + term_width=20, + **kwargs, + ) + bar.start() + bar.update(50, force=True) + # Move the rate limiters out of the way (the constructor substitutes a + # default for poll_interval=None) so the width-threshold branch decides. + bar.poll_interval = None + bar._last_update_timer = -1e9 + return bar + + +def test_needs_update_crossing_width_threshold() -> None: + bar = _bar() + bar.value = 90 + assert bar._needs_update() is True + + +def test_needs_update_within_same_width_bucket() -> None: + bar = _bar() + assert bar._last_drawn_value is not None + bar.value = bar._last_drawn_value + assert bar._needs_update() is False + + +@pytest.mark.parametrize( + 'attribute, incomplete_value', + [ + ('value', None), + ('_last_drawn_value', None), + ('term_width', None), + ('term_width', 0), + ('max_value', None), + ('max_value', 0), + ], +) +def test_needs_update_incomplete_state_is_false( + attribute: str, + incomplete_value: typing.Any, +) -> None: + # Each of these used to raise inside the suppress() and fall through + # to False; the explicit guards must preserve that result. + bar = _bar() + bar.value = 90 + setattr(bar, attribute, incomplete_value) + assert bar._needs_update() is False + + +def test_needs_update_unexpected_error_propagates() -> None: + # A genuinely wrong type is a bug and must no longer pass silently. + bar = _bar() + bar.value = 90 + bar.term_width = 'wide' # type: ignore[assignment] + with pytest.raises(TypeError): + bar._needs_update() 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_perf_budget.py b/tests/test_perf_budget.py new file mode 100644 index 00000000..eb65cac8 --- /dev/null +++ b/tests/test_perf_budget.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import io +import sys +import timeit + +import pytest + + +class _TTY(io.StringIO): + def isatty(self) -> bool: + return True + + +def _overhead_ns(n: int = 200_000) -> float: + import progressbar + + fd = _TTY() + base = min( + timeit.timeit(lambda: [None for _ in range(n)], number=1) + for _ in range(3) + ) + wrapped = min( + timeit.timeit( + lambda: [None for _ in progressbar.progressbar(range(n), fd=fd)], + number=1, + ) + for _ in range(3) + ) + return (wrapped - base) / n * 1e9 + + +def _clock_read_ns(n: int = 200_000) -> float: + """Per-iteration cost of a single ``timeit.default_timer()`` read. + + Used as a machine-independent yardstick: it scales with the interpreter + and runner speed exactly like the progress-bar wrapper does, so the ratio + between them is stable across machines (dev, CI, different Python builds). + """ + timer = timeit.default_timer + base = min( + timeit.timeit(lambda: [None for _ in range(n)], number=1) + for _ in range(5) + ) + read = min( + timeit.timeit(lambda: [timer() for _ in range(n)], number=1) + for _ in range(5) + ) + return (read - base) / n * 1e9 + + +def _coverage_active() -> bool: + """Return True when a coverage tracer (sys.settrace) is installed. + + pytest-cov installs a CTracer that adds per-line overhead to every + Python frame, distorting the measured iterator cost. The budget + assertion is skipped when tracing is active; the lines still *execute* + (satisfying the 100 % coverage gate), only the assert is guarded. + """ + return sys.gettrace() is not None + + +@pytest.mark.no_freezegun +def test_iterator_overhead_budget() -> None: + # Measure both before any early return so every line runs under coverage. + ns = _overhead_ns() + clock_ns = _clock_read_ns() + if _coverage_active(): + # Coverage tracing inflates per-frame cost; run the measurement (so + # all lines are covered) but skip the assertion. The CI perf-budget + # step runs with --no-cov, where the assertion is enforced. + return + # Machine-independent guard. The OLD (pre-gate) path read the clock on + # every iteration, so its overhead was ~9x a single clock read; the gated + # path reads no clock on the common iteration, so its overhead is ~1x. + # A 4x ceiling sits comfortably between the two and tolerates slow/noisy + # CI runners and different Python builds (absolute ns vary wildly; the + # ratio does not). The point is to catch a return of the per-iteration + # clock-read regime, not to micro-police nanoseconds. + assert ns < 4 * clock_ns, ( + f'iterator overhead {ns:.1f} ns/iter exceeded 4x a clock read ' + f'({clock_ns:.1f} ns) - likely a regression to per-iteration ' + f'clock reads' + ) diff --git a/tests/test_progressbar.py b/tests/test_progressbar.py index 4d2424dc..7accb586 100644 --- a/tests/test_progressbar.py +++ b/tests/test_progressbar.py @@ -1,5 +1,370 @@ +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 ( + bar as bar_module, + 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_progressbar_accepts_total_alias() -> None: + bar = progressbar.ProgressBar(total=5, fd=io.StringIO()) + assert bar.max_value == 5 + + +def test_progressbar_max_value_wins_over_total() -> None: + bar = progressbar.ProgressBar(max_value=7, total=5, fd=io.StringIO()) + assert bar.max_value == 7 + + +def test_progressbar_desc_maps_to_prefix() -> None: + stream = io.StringIO() + with progressbar.ProgressBar( + desc='Loading', + max_value=1, + fd=stream, + ) as bar: + bar.update(1, force=True) + assert 'Loading' in stream.getvalue() + + +def test_progressbar_postfix_updates_live() -> None: + stream = io.StringIO() + widgets = [progressbar.Postfix()] + with progressbar.ProgressBar( + max_value=2, + widgets=widgets, + postfix={'loss': 1.0}, + fd=stream, + ) as bar: + bar.update(1, postfix={'loss': 0.5}, force=True) + assert 'loss=0.5' in stream.getvalue() + + +def test_progressbar_postfix_preserves_default_widgets() -> None: + stream = io.StringIO() + with progressbar.ProgressBar( + max_value=2, + postfix='ok', + fd=stream, + ) as bar: + bar.update(2, force=True) + rendered = stream.getvalue() + assert 'ok' in rendered + assert '100%' in rendered or '(2 of 2)' in rendered + + +def test_progressbar_empty_desc_maps_to_prefix() -> None: + stream = io.StringIO() + with progressbar.ProgressBar(desc='', max_value=1, fd=stream) as bar: + bar.update(1, force=True) + assert stream.getvalue().startswith(': ') + + +def test_shortcut_passes_total_desc_and_postfix() -> None: + stream = io.StringIO() + values = list( + progressbar.progressbar( + range(2), + total=2, + desc='Items', + postfix='ok', + fd=stream, + ) + ) + assert values == [0, 1] + rendered = stream.getvalue() + assert 'Items' in rendered + assert 'ok' in rendered + + +def test_elapsed_data_spans_days() -> None: + # Regression: A1 - days_elapsed was computed from timedelta.seconds, + # which only contains the sub-day component. + 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) + + +@pytest.mark.no_freezegun +def test_data_is_a_pure_snapshot(monkeypatch) -> None: + # `data()` must be a pure read of the current state: calling it must not + # mutate the timing fields (`_last_update_time` / `_last_update_timer`). + # The redraw path refreshes those via `_mark_update()`, not the getter. + # + # A strictly-increasing clock makes any hidden mutation observable: on the + # old code each data() call re-stamped the fields with a fresh (larger) + # value, so two calls would disagree. + import timeit as _timeit + + import progressbar.bar as bar_module + + ticks = iter(range(1_700_000_000, 1_700_001_000)) + + def fake_clock() -> float: + return float(next(ticks)) + + bar = progressbar.ProgressBar( + max_value=10, fd=io.StringIO(), term_width=60 + ) + bar.start() + + monkeypatch.setattr(bar_module.time, 'time', fake_clock) + monkeypatch.setattr(_timeit, 'default_timer', fake_clock) + + time_before = bar._last_update_time + timer_before = bar._last_update_timer + + first = bar.data() + second = bar.data() + + # Neither the wall-clock nor the perf-counter timing state may change. + assert bar._last_update_time == time_before + assert bar._last_update_timer == timer_before + # And the two snapshots agree on the timing-derived fields. + assert first['last_update_time'] == second['last_update_time'] + assert first['total_seconds_elapsed'] == second['total_seconds_elapsed'] + assert first['time_elapsed'] == second['time_elapsed'] + + +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) + baseline_capturing = utils.streams.capturing + + bar = progressbar.ProgressBar(max_value=5, fd=io.StringIO(), term_width=60) + bar.start() + bar_id = id(bar) + # The listener registry deliberately owns running bars. Remove this + # test's artificial reference so the finalizer path is exercised. + utils.streams.listeners.discard(bar) + bar.fd = ExplodingIO() + del bar + gc.collect() + + assert not unraisable + assert all(id(listener) != bar_id for listener in utils.streams.listeners) + assert utils.streams.capturing == baseline_capturing + + +def test_finish_cleans_stream_listener_when_render_fails() -> None: + class ExplodingIO(io.StringIO): + def write(self, value: str) -> int: + raise ValueError('I/O operation on closed file') + + bar = progressbar.ProgressBar(max_value=5, fd=io.StringIO(), term_width=60) + bar.start() + assert bar in utils.streams.listeners + + bar.fd = ExplodingIO() + with pytest.raises(ValueError, match='I/O operation on closed file'): + bar.finish() + + assert bar not in utils.streams.listeners + + +def test_start_cleans_stream_listener_when_validation_fails() -> None: + bar = progressbar.ProgressBar( + min_value=-2, + max_value=-1, + fd=io.StringIO(), + ) + + with pytest.raises(ValueError, match='max_value out of range'): + bar.update(-1) + + assert bar not in utils.streams.listeners + + +def test_start_preserves_original_error_when_base_cleanup_fails( + monkeypatch, +) -> None: + def fail_start(self, max_value=None): + raise ValueError('resize start failed') + + def fail_finish(self): + raise RuntimeError('base cleanup failed') + + monkeypatch.setattr(bar_module.ResizableMixin, 'start', fail_start) + monkeypatch.setattr(bar_module.ProgressBarBase, 'finish', fail_finish) + + bar = progressbar.ProgressBar(max_value=5, fd=io.StringIO()) + with pytest.raises(ValueError, match='resize start failed'): + bar.start() + + +@pytest.mark.skipif(os.name == 'nt', reason='SIGWINCH is POSIX-only') +def test_sigwinch_restored_with_overlapping_bars() -> None: + # Regression: A5 - with two live bars, finishing them in creation + # order left a dangling handler installed. + import progressbar.bar as bar_module + + 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(bar_module._ResizeRegistry.bars) + saved_prev = bar_module._ResizeRegistry.previous_handler + bar_module._ResizeRegistry.bars.clear() + bar_module._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: + bar_module._ResizeRegistry.bars.add(restored_bar) + bar_module._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..771fca41 --- /dev/null +++ b/tests/test_progressbar_command.py @@ -0,0 +1,290 @@ +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_sleep_for_rate_limit_skips_when_unset(monkeypatch) -> None: + calls = [] + monkeypatch.setattr(main.time, 'sleep', calls.append) + main._sleep_for_rate_limit(None, transferred=1024, started_at=0, now=1) + assert calls == [] + + +def test_sleep_for_rate_limit_sleeps_when_ahead(monkeypatch) -> None: + calls = [] + monkeypatch.setattr(main.time, 'sleep', calls.append) + main._sleep_for_rate_limit(1024, transferred=2048, started_at=0, now=1) + assert calls == [1] + + +def test_sleep_for_rate_limit_skips_when_on_schedule(monkeypatch) -> None: + calls = [] + monkeypatch.setattr(main.time, 'sleep', calls.append) + main._sleep_for_rate_limit(1024, transferred=1024, started_at=0, now=1) + assert calls == [] + + +def test_main_passes_rate_limit(tmp_path, monkeypatch) -> None: + sleeps = [] + monkeypatch.setattr(main.time, 'sleep', sleeps.append) + monkeypatch.setattr(main.time, 'monotonic', lambda: 0) + file = tmp_path / 'data.bin' + file.write_bytes(b'x' * 2048) + main.main( + ['--rate-limit', '1k', str(file), '-o', str(tmp_path / 'out.bin')], + ) + assert sleeps + + +def test_filename_to_bytes(tmp_path) -> None: + file = tmp_path / 'test' + file.write_text('test') + 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) + + class RecordingNullBar(progressbar.NullBar): + def __init__(self, **kwargs) -> None: + created.append(self) + self.init_kwargs = kwargs + super().__init__(**kwargs) + + monkeypatch.setattr(main.progressbar, 'ProgressBar', RecordingProgressBar) + monkeypatch.setattr(main.progressbar, 'NullBar', RecordingNullBar) + return created + + +def test_build_widgets_honors_display_flags() -> None: + parser = main.create_argument_parser() + args = parser.parse_args( + ['--progress', '--timer', '--eta', '--rate', '--bytes'] + ) + widgets = main._build_widgets(args, filesize_available=True) + widget_types = tuple( + type(widget) for widget in widgets if not isinstance(widget, str) + ) + assert progressbar.Percentage in widget_types + assert progressbar.Bar in widget_types + assert progressbar.Timer in widget_types + assert progressbar.AdaptiveETA in widget_types + assert progressbar.FileTransferSpeed in widget_types + assert progressbar.DataSize in widget_types + + +def test_build_widgets_quiet_is_empty() -> None: + parser = main.create_argument_parser() + args = parser.parse_args(['--quiet']) + assert main._build_widgets(args, filesize_available=True) == [] + + +def test_main_quiet_uses_null_bar(tmp_path, recorded_bars) -> None: + file = tmp_path / 'data.bin' + file.write_bytes(b'x' * 16) + main.main(['--quiet', str(file), '-o', str(tmp_path / 'out.bin')]) + + assert isinstance(recorded_bars[0], progressbar.NullBar) + + +def test_numeric_output_uses_line_breaks(tmp_path, recorded_bars) -> None: + file = tmp_path / 'data.bin' + file.write_bytes(b'x' * 16) + main.main(['--numeric', str(file), '-o', str(tmp_path / 'out.bin')]) + assert recorded_bars[0].init_kwargs['line_breaks'] is True + assert any( + isinstance(widget, progressbar.Percentage) + for widget in recorded_bars[0].init_kwargs['widgets'] + ) + + +def test_main_passes_widgets(tmp_path, recorded_bars) -> None: + # Regression: E2 - the configured widgets were built but never passed + # to the progress bar. + 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_readme_demos.py b/tests/test_readme_demos.py new file mode 100644 index 00000000..68935262 --- /dev/null +++ b/tests/test_readme_demos.py @@ -0,0 +1,352 @@ +import os +import re +import sys +import textwrap +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import scripts.render_readme_demos as demos # noqa: E402 + + +def _bar_widths(frames: list[list[str]]) -> list[int]: + return [ + len(match.group('inner')) + for frame in frames + for line in frame + if (match := demos.BAR_RE.search(line)) + ] + + +def _indented_snippet(snippet: str) -> str: + return '\n'.join( + f' {line}' if line else '' + for line in textwrap.dedent(snippet).strip().splitlines() + ) + + +def _readme_demo_block(demo: demos.Demo, alt: str) -> str: + return ( + f'.. image:: docs/_static/progressbar-{demo.name}.svg\n' + f' :alt: {alt}\n\n' + '.. code:: python\n\n' + f'{_indented_snippet(demo.snippet)}' + ) + + +def test_render_svg_escapes_terminal_text(tmp_path: Path) -> None: + output = tmp_path / 'demo.svg' + demos.render_svg( + output, + title='Demo', + frames=[ + ['', 'progress 0%'], + ['done & clean', 'progress 100%'], + ], + ) + text = output.read_text(encoding='utf-8') + assert '<start>' in text + assert 'done & clean' in text + assert ' None: + output = tmp_path / 'demo.svg' + demos.render_svg( + output, + title='Demo', + frames=[ + [ + 'Build: 50% (1 of 2) |## | ETA: 0:00:00 loss=0.5', + 'log: completed step 1', + ], + ], + ) + + text = output.read_text(encoding='utf-8') + assert 'class="terminal-label">Build:' in text + assert 'class="terminal-percent">50%' in text + assert 'class="terminal-bar-fill">##' in text + assert 'class="terminal-bar-empty"> ' in text + assert 'class="terminal-postfix">loss=0.5' in text + assert 'class="terminal-log">log:' in text + + +def test_render_svg_preserves_actual_ansi_truecolor_segments( + tmp_path: Path, +) -> None: + output = tmp_path / 'demo.svg' + demos.render_svg( + output, + title='Demo', + frames=[ + ['\x1b[38;2;255;0;0m 0%\x1b[39m \x1b[38;2;0;255;0m100%\x1b[39m'], + ], + ) + + text = output.read_text(encoding='utf-8') + assert 'style="fill: #ff0000"> 0%' in text + assert 'style="fill: #00ff00">100%' in text + assert 'class="terminal-percent"' not in text + assert '\x1b' not in text + + +def test_styled_terminal_line_keeps_spinner_pipe_outside_bar() -> None: + styled = demos.styled_terminal_line('| |# | 31 Elapsed Time: 0:00:00') + + assert styled.startswith( + '| |' + '#' + ) + spinner_bar = ( + 'terminal-bar-empty"> ' + ) + assert spinner_bar not in styled + + +def test_demo_definitions_are_ordered_and_exercise_key_features() -> None: + assert [(demo.name, demo.title) for demo in demos.DEMOS] == [ + ('hero', 'Progress with clean logs'), + ('multibar', 'Multiple active jobs'), + ('unknown-length', 'Unknown length'), + ] + + snippets = {demo.name: demo.snippet for demo in demos.DEMOS} + assert 'redirect_stdout=True' in snippets['hero'] + assert 'progressbar.MultiBar' in snippets['multibar'] + assert 'io.StringIO()' in snippets['multibar'] + assert 'fd.getvalue()' in snippets['multibar'] + assert '.fd.line' not in snippets['multibar'] + assert 'build: {build.value}/3' not in snippets['multibar'] + assert 'progressbar.UnknownLength' in snippets['unknown-length'] + assert 'for value in range(0, 120, 10):' in snippets['unknown-length'] + assert 'for value in (' not in snippets['unknown-length'] + + +def test_readme_uses_branch_relative_demo_assets() -> None: + readme = (demos.ROOT / 'README.rst').read_text(encoding='utf-8') + + assert ( + 'raw.githubusercontent.com/WoLpH/python-progressbar/develop' + not in readme + ) + assert '.. image:: docs/_static/progressbar-hero.svg' in readme + assert '.. image:: docs/_static/progressbar-multibar.svg' in readme + assert '.. image:: docs/_static/progressbar-unknown-length.svg' in readme + assert 'progressbar-ergonomics.svg' not in readme + assert 'Tqdm-style ergonomic options' not in readme + + +def test_readme_omits_obsolete_gpg_release_verification() -> None: + readme = (demos.ROOT / 'README.rst').read_text(encoding='utf-8') + + assert 'Release verification' not in readme + assert 'GPG' not in readme + assert 'pgp.mit.edu' not in readme + assert '.tar.gz.asc' not in readme + + +def test_readme_places_exact_demo_code_after_each_animation() -> None: + readme = (demos.ROOT / 'README.rst').read_text(encoding='utf-8') + alt_by_name = { + 'hero': 'progressbar2 showing clean progress output with logs', + 'multibar': 'multiple progress bars updating together', + 'unknown-length': 'unknown length progress with an animated marker', + } + + for demo in demos.DEMOS: + assert _readme_demo_block(demo, alt_by_name[demo.name]) in readme + + +def test_render_svg_first_frame_is_visible_without_animation( + tmp_path: Path, +) -> None: + output = tmp_path / 'demo.svg' + demos.render_svg( + output, + title='Demo', + frames=[ + ['first'], + ['second'], + ], + ) + + text = output.read_text(encoding='utf-8') + assert text.count('') == 1 + assert text.count('') == 1 + assert ' None: + output = tmp_path / 'demo.svg' + demos.render_svg(output, title='Demo', frames=[['a'], ['b'], ['c']]) + + text = output.read_text(encoding='utf-8') + assert 'dur="0.24s"' in text + assert 'dur="3.6s"' not in text + assert '3.599999999' not in text + + +def test_render_svg_uses_wide_canvas_for_readable_bars( + tmp_path: Path, +) -> None: + output = tmp_path / 'demo.svg' + demos.render_svg(output, title='Demo', frames=[['a']]) + + text = output.read_text(encoding='utf-8') + assert 'width="1080"' in text + assert 'viewBox="0 0 1080 96"' in text + + +def test_multibar_demo_captures_rendered_progressbar_output() -> None: + demo = next(demo for demo in demos.DEMOS if demo.name == 'multibar') + frames = demos.capture_demo(demo) + text = '\n'.join(line for frame in frames for line in frame) + + assert frames + assert all(len(frame) == 2 for frame in frames) + assert 'build: 1/3 | test: 1/3' not in text + assert 'build' in text + assert 'test' in text + assert 'Elapsed Time:' in text + assert 'ETA:' in text + assert '(0 of 24)' in text + assert '(1 of 24)' in text or '(2 of 24)' in text + assert '(24 of 24)' in text + + +def test_multibar_demo_shows_independent_progress_values() -> None: + demo = next(demo for demo in demos.DEMOS if demo.name == 'multibar') + frames = demos.capture_demo(demo) + + mismatched_values = [] + for frame in frames: + values = [ + int(match.group(1)) + for line in frame + if (match := re.search(r'\((\d+) of 24\)', line)) + ] + if len(values) == 2 and values[0] != values[1]: + mismatched_values.append(values) + + assert mismatched_values + + +def test_capture_demo_timeout_raises_clear_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_timeout(*args: object, **kwargs: object) -> object: + raise demos.subprocess.TimeoutExpired(cmd=['python'], timeout=5) + + demo = demos.Demo('sample', 'Sample', '') + monkeypatch.setattr(demos.subprocess, 'run', raise_timeout) + + with pytest.raises(SystemExit) as error: + demos.capture_demo(demo) + + assert str(error.value) == 'timed out capturing demo: sample' + + +def test_capture_frames_splits_carriage_return_output() -> None: + frames = demos.frames_from_output('zero\rone\ntwo\rthree') + assert frames == [['zero'], ['one'], ['two'], ['three']] + + +def test_capture_frames_keeps_more_animation_states() -> None: + frames = demos.frames_from_output( + '\n'.join(f'frame {index}' for index in range(30)) + ) + + assert len(frames) == 24 + assert frames[0] == ['frame 0'] + assert frames[-1] == ['frame 29'] + + +def test_readme_demos_emit_enough_frames_to_look_responsive() -> None: + frame_counts = { + demo.name: len(demos.capture_demo(demo)) for demo in demos.DEMOS + } + + assert frame_counts['hero'] == 24 + assert frame_counts['multibar'] == 24 + assert frame_counts['unknown-length'] >= 8 + + +def test_readme_demos_show_wide_determinate_progress_bars() -> None: + frames_by_name = { + demo.name: demos.capture_demo(demo) for demo in demos.DEMOS + } + + for name in ('hero', 'multibar'): + text = '\n'.join( + line for frame in frames_by_name[name] for line in frame + ) + first_frame = '\n'.join(frames_by_name[name][0]) + last_frame = '\n'.join(frames_by_name[name][-1]) + assert '0%' in first_frame + assert '100%' in last_frame + assert '0%' in text + assert '100%' in text + assert max(_bar_widths(frames_by_name[name])) >= 32 + + +@pytest.mark.skipif( + os.name == 'nt', + reason='capture_demo renders in a subprocess; on Windows the color ' + 'detection reports the WINDOWS level whose color path is a no-op, so ' + 'truecolor escapes are never emitted (demos are generated on posix)', +) +def test_readme_demos_capture_real_percentage_color_changes() -> None: + demo = next(demo for demo in demos.DEMOS if demo.name == 'hero') + text = '\n'.join( + line for frame in demos.capture_demo(demo) for line in frame + ) + + assert '\x1b[38;2;255;0;0m' in text + assert '\x1b[38;2;0;255;0m' in text + + +def test_hero_demo_keeps_recent_logs_visible_above_progress() -> None: + demo = next(demo for demo in demos.DEMOS if demo.name == 'hero') + frames = demos.capture_demo(demo) + + assert max(len(frame) for frame in frames) >= 3 + assert any( + any(line.startswith('log: completed step 8') for line in frame) + and any('Build:' in line for line in frame) + for frame in frames + ) + + +def test_capture_frames_normalizes_variable_timing_text() -> None: + frames = demos.frames_from_output( + 'Build: 50% Elapsed Time: 0:01:23 ETA: 9:08:07\n' + 'Build: 100% Elapsed Time: 0:00:04 Time: 2:03:04' + ) + assert frames == [ + ['Build: 50% Elapsed Time: 0:00:00 ETA: 0:00:00'], + ['Build: 100% Elapsed Time: 0:00:00 Time: 0:00:00'], + ] + + +def test_check_mode_does_not_rewrite_mismatched_asset( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + demo = demos.Demo('sample', 'Sample', '') + output = tmp_path / 'progressbar-sample.svg' + output.write_text('stale asset', encoding='utf-8') + + monkeypatch.setattr(demos, 'DEMOS', [demo]) + monkeypatch.setattr(demos, 'STATIC_DIR', tmp_path) + monkeypatch.setattr(demos, 'capture_demo', lambda demo: [['fresh asset']]) + monkeypatch.setattr(sys, 'argv', ['render_readme_demos.py', '--check']) + + with pytest.raises(SystemExit) as error: + demos.main() + + assert 'outdated generated asset' in str(error.value) + assert output.read_text(encoding='utf-8') == 'stale asset' diff --git a/tests/test_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..30bdaa9b --- /dev/null +++ b/tests/test_stream.py @@ -0,0 +1,487 @@ +import io +import logging +import os +import sys + +import pytest + +import progressbar +from progressbar import terminal + + +def reset_wrapped_streams() -> None: + while progressbar.streams.wrapped_logging: + progressbar.streams.unwrap_logging() + while ( + progressbar.streams.wrapped_stdout + or progressbar.streams.wrapped_stderr + ): + progressbar.streams.unwrap(stderr=True, stdout=True) + progressbar.streams.wrapped_logging = 0 + progressbar.streams.wrapped_stdout = 0 + progressbar.streams.wrapped_stderr = 0 + progressbar.streams.logging_handlers.clear() + for listener in list(progressbar.streams.listeners): + listener._finished = True + progressbar.streams.listeners.clear() + progressbar.streams.capturing = 0 + progressbar.streams.update_capturing() + + +def test_nowrap() -> None: + # Make sure we definitely unwrap + reset_wrapped_streams() + + 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 + reset_wrapped_streams() + + +def test_wrap() -> None: + # Make sure we definitely unwrap + reset_wrapped_streams() + + 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 + reset_wrapped_streams() + + +def test_wrap_logging_retargets_existing_stderr_handler(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + monkeypatch.setattr(sys, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-wrap-logging') + logger.handlers = [] + logger.propagate = False + handler = logging.StreamHandler(sys.stderr) + logger.addHandler(handler) + + progressbar.streams.wrap_stderr() + progressbar.streams.wrap_logging() + try: + assert handler.stream is progressbar.streams.stderr + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_unwrap_logging_restores_handler_stream(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-unwrap-logging') + logger.handlers = [] + logger.propagate = False + handler = logging.StreamHandler(sys.stderr) + logger.addHandler(handler) + + progressbar.streams.wrap_stderr() + progressbar.streams.wrap_logging() + progressbar.streams.unwrap_logging() + + try: + assert handler.stream is stream + finally: + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_unwrap_logging_restores_handler_created_after_stderr_wrap( + monkeypatch, +) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-wrapped-stderr-handler') + logger.handlers = [] + logger.propagate = False + + progressbar.streams.wrap_stderr() + wrapped_stderr = progressbar.streams.stderr + handler = logging.StreamHandler(sys.stderr) + logger.addHandler(handler) + + try: + assert handler.stream is wrapped_stderr + + progressbar.streams.wrap_logging() + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + + assert handler.stream is stream + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_wrap_logging_handles_nested_calls(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-nested-wrap-logging') + logger.handlers = [] + logger.propagate = False + handler = logging.StreamHandler(sys.stderr) + logger.addHandler(handler) + + progressbar.streams.wrap_stderr() + progressbar.streams.wrap_logging() + progressbar.streams.wrap_logging() + try: + assert progressbar.streams.wrapped_logging == 2 + progressbar.streams.unwrap_logging() + assert progressbar.streams.wrapped_logging == 1 + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_wrap_logging_retargets_existing_stdout_handler(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stdout', stream) + monkeypatch.setattr(progressbar.streams, 'original_stdout', stream) + monkeypatch.setattr(progressbar.streams, 'stdout', stream) + + logger = logging.getLogger('progressbar-test-wrap-stdout-logging') + logger.handlers = [] + logger.propagate = False + handler = logging.StreamHandler(sys.stdout) + logger.addHandler(handler) + + progressbar.streams.wrap_stdout() + progressbar.streams.wrap_logging() + try: + assert handler.stream is progressbar.streams.stdout + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stdout=True) + logger.handlers = [] + + +def test_wrap_logging_ignores_handlers_that_reject_streams( + monkeypatch, +) -> None: + class RejectingHandler(logging.StreamHandler): + def setStream(self, stream): # noqa: N802 + raise ValueError('stream rejected') + + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-rejecting-handler') + logger.handlers = [] + logger.propagate = False + handler = RejectingHandler(sys.stderr) + logger.addHandler(handler) + + progressbar.streams.wrap_stderr() + try: + progressbar.streams.wrap_logging() + assert all( + logged_handler is not handler + for logged_handler, _stream in progressbar.streams.logging_handlers + ) + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_wrap_logging_uses_handler_snapshot(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-mutating-handlers') + logger.handlers = [] + logger.propagate = False + first_handler = logging.StreamHandler(sys.stderr) + late_handler = logging.StreamHandler(sys.stderr) + logger.addHandler(first_handler) + + wrapped_handlers: list[logging.StreamHandler] = [] + original_wrap_handler = progressbar.streams._wrap_logging_handler + + def mutating_wrap_handler(handler, wrapped_streams, restore_streams): + wrapped_handlers.append(handler) + if handler is first_handler: + logger.addHandler(late_handler) + original_wrap_handler(handler, wrapped_streams, restore_streams) + + monkeypatch.setattr( + progressbar.streams, + '_wrap_logging_handler', + mutating_wrap_handler, + ) + + progressbar.streams.wrap_stderr() + try: + progressbar.streams.wrap_logging() + assert first_handler in wrapped_handlers + assert late_handler not in wrapped_handlers + assert late_handler.stream is stream + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_unwrap_logging_ignores_dynamic_stderr_handler(monkeypatch) -> None: + reset_wrapped_streams() + + stream = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stream) + monkeypatch.setattr(progressbar.streams, 'original_stderr', stream) + monkeypatch.setattr(progressbar.streams, 'stderr', stream) + + logger = logging.getLogger('progressbar-test-dynamic-stderr-handler') + logger.handlers = [] + logger.propagate = False + handler = logging._StderrHandler() # type: ignore[attr-defined] + logger.addHandler(handler) + + try: + progressbar.streams.wrap_stderr() + assert handler.stream is progressbar.streams.stderr + + progressbar.streams.wrap_logging() + progressbar.streams.unwrap_logging() + finally: + progressbar.streams.unwrap_logging() + progressbar.streams.unwrap(stderr=True) + logger.handlers = [] + + +def test_redirected_stdout_lines_are_flushed_above_bar(monkeypatch) -> None: + reset_wrapped_streams() + output = io.StringIO() + monkeypatch.setattr(progressbar.streams, 'original_stdout', output) + monkeypatch.setattr(progressbar.streams, 'stdout', output) + monkeypatch.setattr(sys, 'stdout', output) + + with progressbar.ProgressBar( + max_value=2, + fd=output, + redirect_stdout=True, + line_breaks=False, + is_terminal=True, + term_width=40, + ) as bar: + print('phase one') + bar.update(1, force=True) + print('phase two') + bar.update(2, force=True) + + rendered = output.getvalue() + assert 'phase one' in rendered + assert 'phase two' in rendered + assert '\r' + ' ' * 40 + '\rphase two\n' in rendered + assert rendered.endswith('\n') + + +def test_redirected_stderr_lines_are_flushed_above_bar(monkeypatch) -> None: + reset_wrapped_streams() + output = io.StringIO() + monkeypatch.setattr(progressbar.streams, 'original_stderr', output) + monkeypatch.setattr(progressbar.streams, 'stderr', output) + monkeypatch.setattr(sys, 'stderr', output) + + with progressbar.ProgressBar( + max_value=2, + fd=output, + redirect_stderr=True, + line_breaks=False, + is_terminal=True, + term_width=40, + ) as bar: + print('warning one', file=sys.stderr) + bar.update(1, force=True) + print('warning two', file=sys.stderr) + bar.update(2, force=True) + + rendered = output.getvalue() + assert 'warning one' in rendered + assert 'warning two' in rendered + assert '\r' + ' ' * 40 + '\rwarning two\n' in rendered + assert rendered.endswith('\n') + + +def test_excepthook() -> None: + 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_subclass_compat.py b/tests/test_subclass_compat.py new file mode 100644 index 00000000..01c91522 --- /dev/null +++ b/tests/test_subclass_compat.py @@ -0,0 +1,493 @@ +"""Third-party subclass compatibility characterization tests. + +progressbar2 is subclassed in the wild in two styles: + +1. *Old style* — explicit unbound parent ``__init__`` calls, copying the + library's own historic pattern + (``FormatWidgetMixin.__init__(self, ...)`` followed by + ``WidgetBase.__init__(self, ...)``). +2. *Super style* — a single cooperative ``super().__init__(...)``. + +Both styles must construct and render identically before and after the +cooperative-``super()`` migration; these tests characterize today's +behavior and act as the gate for that refactor. The golden-render tests +additionally pin the exact default rendering so any refactor that +changes output byte-for-byte fails loudly. +""" + +from __future__ import annotations + +import io +import typing + +import pytest + +import progressbar +import progressbar.bar +import progressbar.widgets + +# Alias (not a `from` import) so CodeQL doesn't flag `progressbar` as +# imported with both `import` and `import from`. +widgets = progressbar.widgets +bar_module = progressbar.bar + + +def _render( + widget_list: list[typing.Any], + max_value: int = 10, + term_width: int = 60, +) -> str: + fd = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd, + max_value=max_value, + widgets=widget_list, + term_width=term_width, + ) + bar.start() + bar.update(5, force=True) + bar.finish() + return fd.getvalue() + + +# --- (a) old style: explicit unbound parent __init__ calls ----------------- + + +class OldStyleWidget(widgets.FormatWidgetMixin, widgets.WidgetBase): + def __init__( + self, + my_param: str = 'x', + format: str = '%(value)d!', + **kwargs: typing.Any, + ): + self.my_param = my_param + widgets.FormatWidgetMixin.__init__(self, format=format, **kwargs) + widgets.WidgetBase.__init__(self, **kwargs) + + def __call__(self, progress, data, format=None): + return widgets.FormatWidgetMixin.__call__(self, progress, data) + + +class OldStyleCounterClone(widgets.FormatWidgetMixin, widgets.WidgetBase): + """Copies the library's historic ``format=``-leak style verbatim.""" + + def __init__(self, format: str = '%(value)d', **kwargs: typing.Any): + widgets.FormatWidgetMixin.__init__(self, format=format, **kwargs) + widgets.WidgetBase.__init__(self, format=format, **kwargs) + + def __call__(self, progress, data, format=None): + return widgets.FormatWidgetMixin.__call__(self, progress, data) + + +class OldStyleSamplesWidget(widgets.SamplesMixin): + def __init__(self, **kwargs: typing.Any): + # samples accepts int per the class docstring/doctest. + widgets.SamplesMixin.__init__( + self, + samples=3, + **kwargs, + ) + + def __call__(self, progress, data): # pragma: no cover - never rendered + return str(widgets.SamplesMixin.__call__(self, progress, data)) + + +def test_old_style_widget_constructs_and_renders() -> None: + widget = OldStyleWidget(min_width=1, max_width=100) + # kwargs must keep reaching WidthWidgetMixin through both parent calls + assert widget.min_width == 1 + assert widget.max_width == 100 + assert widget.format == '%(value)d!' + assert '5!' in _render([widget]) + + +def test_old_style_format_leak_still_constructs() -> None: + # Must never raise, before or after the super() migration. + widget = OldStyleCounterClone(min_width=2) + assert widget.min_width == 2 + assert '5' in _render([widget]) + + +def test_old_style_samples_mixin() -> None: + widget = OldStyleSamplesWidget() + assert widget.samples == 3 + assert widget.key_prefix == 'OldStyleSamplesWidget_' + + +# --- (b) super style: single cooperative call ------------------------------- + + +class SuperStyleWidget(widgets.FormatWidgetMixin, widgets.WidgetBase): + def __init__( + self, + my_param: str = 'x', + format: str = '%(value)d?', + **kwargs: typing.Any, + ): + self.my_param = my_param + super().__init__(format=format, **kwargs) + + def __call__(self, progress, data, format=None): + return super().__call__(progress, data) + + +def test_super_style_widget_sets_format() -> None: + # FormatWidgetMixin is first in the MRO, so this works even today. + widget = SuperStyleWidget() + assert widget.format == '%(value)d?' + + +def test_super_style_widget_constructs_and_renders() -> None: + widget = SuperStyleWidget(min_width=1) + assert widget.min_width == 1 + assert widget.format == '%(value)d?' + assert '5?' in _render([widget]) + + +def test_super_style_diamond_subclass() -> None: + class MyETA(widgets.AdaptiveETA): + def __init__(self, **kwargs: typing.Any): + super().__init__(samples=7, **kwargs) + + widget = MyETA() + assert widget.samples == 7 + _render([widget]) + + +# --- library widgets: constructor kwargs must land where they belong ------- + + +@pytest.mark.parametrize( + 'widget_class, args', + [ + (widgets.FormatLabel, ('%(value)s',)), + (widgets.Timer, ()), + (widgets.ETA, ()), + (widgets.AdaptiveETA, ()), + (widgets.Counter, ()), + (widgets.Percentage, ()), + (widgets.SimpleProgress, ()), + (widgets.PercentageLabelBar, ()), + (widgets.FormatLabelBar, ('%(value)s',)), + ], +) +def test_width_kwargs_reach_width_mixin(widget_class, args) -> None: + widget = widget_class(*args, min_width=3, max_width=90) + assert widget.min_width == 3 + assert widget.max_width == 90 + + +def test_variable_name_reaches_variable_mixin() -> None: + assert widgets.MultiRangeBar('jobs', markers=[' ', '#']).name == 'jobs' + assert widgets.JobStatusBar('status').name == 'status' + assert widgets.Variable('speed', precision=2).name == 'speed' + + +# --- ProgressBar subclasses, both styles ------------------------------------ + + +class OldStyleBar(progressbar.ProgressBar): + """Copies the library's current explicit-parent-call style.""" + + def __init__(self, *args: typing.Any, **kwargs: typing.Any): + progressbar.ProgressBar.__init__(self, *args, **kwargs) + self.custom = True + + +class SuperStyleBar(progressbar.ProgressBar): + def __init__( + self, + *args: typing.Any, + my_option: str | None = None, + **kwargs: typing.Any, + ): + self.my_option = my_option + super().__init__(*args, **kwargs) + + def start(self, *args: typing.Any, **kwargs: typing.Any): + self.start_hook = True + return super().start(*args, **kwargs) + + def update(self, value=None, force=False, **kwargs: typing.Any): + self.update_hook = getattr(self, 'update_hook', 0) + 1 + super().update(value, force=force, **kwargs) + + +@pytest.mark.parametrize( + 'bar_class', + [OldStyleBar, SuperStyleBar, progressbar.ProgressBar], +) +def test_bar_subclass_lifecycle(bar_class) -> None: + fd = io.StringIO() + with bar_class(fd=fd, max_value=5, term_width=60) as bar: + for i in range(5): + bar.update(i + 1, force=True) + assert bar.finished() + assert '100%' in fd.getvalue() + + +def test_index_consumed_once_per_bar() -> None: + first = progressbar.ProgressBar( + fd=io.StringIO(), max_value=1, term_width=60 + ) + second = OldStyleBar(fd=io.StringIO(), max_value=1, term_width=60) + # Bar indexes must stay monotonic, exactly one per instance; the + # cooperative migration must not make subclasses consume extra ones. + assert second.index == first.index + 1 + + +# --- golden rendering -------------------------------------------------------- + + +def test_default_widgets_render_identically() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd, + min_value=0, + max_value=10, + term_width=80, + enable_colors=False, + line_breaks=True, + ) + bar.start() + for i in (3, 7, 10): + bar.update(i, force=True) + bar.finish() + out = fd.getvalue() + assert '100%' in out + assert '10 of 10' in out + assert 'Elapsed Time' in out + + +def test_known_length_render_golden() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd, + max_value=10, + term_width=40, + enable_colors=False, + widgets=[ + widgets.Percentage(), + ' ', + widgets.SimpleProgress(), + ' ', + widgets.Bar(), + ], + ) + bar.start() + bar.update(5, force=True) + bar.finish() + assert _final_line(fd) == GOLDEN_KNOWN_LENGTH_FINAL + + +def test_unknown_length_render_golden() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd, + max_value=progressbar.UnknownLength, + term_width=40, + enable_colors=False, + widgets=[widgets.Counter(), ' ', widgets.Timer()], + ) + bar.start() + bar.update(5, force=True) + bar.finish() + assert _final_line(fd) == GOLDEN_UNKNOWN_LENGTH_FINAL + + +def _final_line(fd: io.StringIO) -> str: + lines = [ + line.strip('\r') for line in fd.getvalue().splitlines() if line.strip() + ] + return lines[-1] + + +# Exact expected final lines, captured from the current release behavior +# under the deterministic test clock (frozen time -> zero elapsed). +GOLDEN_KNOWN_LENGTH_FINAL: str = '100% 10 of 10 |########################|' +GOLDEN_UNKNOWN_LENGTH_FINAL: str = '5 Elapsed Time: 0:00:00' + + +# --- post-migration guarantees ---------------------------------------------- + + +def test_no_double_width_mixin_init(monkeypatch: pytest.MonkeyPatch) -> None: + """A cooperative chain must init each base exactly once. + + Pre-migration, ``Timer`` reached ``WidthWidgetMixin.__init__`` twice + (once via ``FormatLabel``/``WidgetBase`` and again via + ``TimeSensitiveWidgetBase``/``WidgetBase``). The single cooperative + chain must run it exactly once. + """ + calls = 0 + original = widgets.WidthWidgetMixin.__init__ + + def counting_init(self, *args: typing.Any, **kwargs: typing.Any) -> None: + nonlocal calls + calls += 1 + original(self, *args, **kwargs) + + monkeypatch.setattr(widgets.WidthWidgetMixin, '__init__', counting_init) + widgets.Timer() + assert calls == 1 + + +class OldStyleTwoPhaseColorWidget( + widgets.FormatWidgetMixin, widgets.WidgetBase +): + """Old-style widget that reaches ``WidgetBase.__init__`` twice. + + The first parent call carries no color kwargs; the second supplies + ``fixed_colors=``. ``uses_colors`` must reflect the *final* state, not + the stale ``False`` cached during the first pass. + """ + + def __init__(self, format: str = '%(value)d', **kwargs: typing.Any): + # First parent call: no color kwargs (would cache uses_colors=False). + widgets.FormatWidgetMixin.__init__(self, format=format) + # Second parent call: colors arrive now. + widgets.WidgetBase.__init__( + self, + fixed_colors=dict(fg_none=widgets.colors.red), + **kwargs, + ) + + def __call__(self, progress, data, format=None): + return widgets.FormatWidgetMixin.__call__(self, progress, data) + + +def test_old_style_two_phase_color_kwargs() -> None: + # Regression: the cached ``uses_colors`` must be dropped between passes + # so late-arriving fixed_colors still enable color rendering. + widget = OldStyleTwoPhaseColorWidget() + assert widget.uses_colors is True + assert widget._len is widgets.utils.len_color + + +def test_super_style_color_kwargs_reach_widget_base() -> None: + # The cooperative path must also route fixed_colors to WidgetBase. + widget = SuperStyleWidget( + fixed_colors=dict(fg_none=widgets.colors.red), + ) + assert widget.uses_colors is True + assert widget._len is widgets.utils.len_color + + +# --- bar.py __init__ chain: cooperative-super() guarantees ------------------ + + +def test_no_double_resizable_mixin_init( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The bar ``__init__`` tower must init each mixin exactly once. + + Pre-migration ``ProgressBar.__init__`` reached + ``ResizableMixin.__init__`` twice: once via + ``StdRedirectMixin`` -> ``DefaultFdMixin.super()`` and again via an + explicit second call. The single cooperative chain must run it + exactly once. + """ + calls = 0 + original = bar_module.ResizableMixin.__init__ + + def counting_init(self, *args: typing.Any, **kwargs: typing.Any) -> None: + nonlocal calls + calls += 1 + original(self, *args, **kwargs) + + monkeypatch.setattr(bar_module.ResizableMixin, '__init__', counting_init) + progressbar.ProgressBar(fd=io.StringIO(), max_value=1, term_width=60) + assert calls == 1 + + +class TripleCallBar(progressbar.ProgressBar): + """Third-party old-style subclass: explicit unbound parent calls. + + Mirrors ``ProgressBar.__init__``'s historic explicit-parent-call + pattern. After the cooperative migration each of these three calls + reaches ``ProgressBarBase.__init__``, so the guarded index + assignment must still consume exactly one index per instance. + """ + + def __init__(self, *args: typing.Any, **kwargs: typing.Any): + bar_module.StdRedirectMixin.__init__(self, *args, **kwargs) + bar_module.ResizableMixin.__init__(self, *args, **kwargs) + bar_module.ProgressBarBase.__init__(self, *args, **kwargs) + + +def test_old_style_triple_call_bar_consumes_one_index() -> None: + first = TripleCallBar(fd=io.StringIO(), max_value=1, term_width=60) + second = TripleCallBar(fd=io.StringIO(), max_value=1, term_width=60) + # Each construction consumes exactly one index despite three explicit + # parent __init__ entry points reaching ProgressBarBase. + assert first.index >= 0 + assert second.index == first.index + 1 + + +# --- update/start/finish chain: cooperative-super() guarantees -------------- + + +def test_super_style_update_override_dispatched_once() -> None: + """A super()-style ``update`` override runs exactly once per call. + + The collapsed ``_update_parents`` chain dispatches to the *parent* + mixins via ``super().update(...)``, so it must never re-enter the + subclass's own ``update`` override. A double dispatch through the + chain would bump the counter twice. + """ + calls = 0 + + class CountingBar(progressbar.ProgressBar): + def update( + self, + value: typing.Any = None, + force: bool = False, + **kwargs: typing.Any, + ) -> None: + nonlocal calls + calls += 1 + super().update(value, force=force, **kwargs) + + bar = CountingBar(fd=io.StringIO(), max_value=10, term_width=60) + # start() itself calls update(min_value); ignore those bootstrap calls. + bar.start() + calls = 0 + bar.update(1, force=True) + assert calls == 1 + bar.finish() + + +def test_finish_end_kwarg_threads_through_chain() -> None: + """``finish(end='')`` still threads ``end`` through the collapsed chain. + + ``end`` is popped inside ``DefaultFdMixin.finish`` after the migration; + an empty value must suppress the trailing newline while the default + still writes one. + """ + fd_blank = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd_blank, + max_value=10, + term_width=60, + enable_colors=False, + line_breaks=False, + ) + bar.start() + bar.update(5, force=True) + bar.finish(end='') + assert bar.finished() + assert not fd_blank.getvalue().endswith('\n') + + # Contrast: the default end='\n' still writes the trailing newline. + fd_newline = io.StringIO() + bar2 = progressbar.ProgressBar( + fd=fd_newline, + max_value=10, + term_width=60, + enable_colors=False, + line_breaks=False, + ) + bar2.start() + bar2.update(5, force=True) + bar2.finish() + assert fd_newline.getvalue().endswith('\n') 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..0eb17745 --- /dev/null +++ b/tests/test_timed.py @@ -0,0 +1,210 @@ +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 + + +def test_eta_with_none_max_value() -> None: + # Regression (#321 review): the explicit UnknownLength guard replaced a + # broad try/except TypeError; ``max_value`` may also legitimately be + # ``None`` for indeterminate bars and must take the N/A path rather + # than crashing on the remaining-count subtraction. + import io + + widget = progressbar.ETA() + bar = progressbar.ProgressBar( + widgets=[widget], + max_value=progressbar.UnknownLength, + fd=io.StringIO(), + term_width=60, + ) + bar.start() + bar.update(1) + data = bar.data() + data['time_elapsed'] = datetime.timedelta(seconds=5) + bar.max_value = None + assert 'N/A' in widget(bar, data) + bar.finish(dirty=True) 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_update_guard.py b/tests/test_update_guard.py new file mode 100644 index 00000000..48457ac1 --- /dev/null +++ b/tests/test_update_guard.py @@ -0,0 +1,48 @@ +"""`ProgressBar.update()` value-guard behavior. + +The value-assignment block guards on ``isinstance(value, (int, float))``. +The two leading clauses it used to also carry -- ``value is not None`` and +``value is not base.UnknownLength`` -- are subsumed by that isinstance +check (``None`` and the ``UnknownLength`` sentinel class both fail it), so +``update(None)`` (a redraw tick) and ``update(UnknownLength)`` (the sentinel) +must leave ``value``/``previous_value`` untouched exactly as before. +""" + +from __future__ import annotations + +import io + +import progressbar +import progressbar.base + +# Alias (not a `from` import) so CodeQL doesn't flag `progressbar` as +# imported with both `import` and `import from`. +base = progressbar.base + + +def _bar() -> progressbar.ProgressBar: + bar = progressbar.ProgressBar(fd=io.StringIO(), max_value=100).start() + bar.update(40) + assert bar.value == 40 + return bar + + +def test_update_none_is_a_tick_and_keeps_value() -> None: + bar = _bar() + previous = bar.previous_value + # A `None` value is a redraw tick, not a new value. + bar.update(None) + assert bar.value == 40 + assert bar.previous_value == previous + bar.finish() + + +def test_update_unknown_length_sentinel_keeps_value() -> None: + bar = _bar() + previous = bar.previous_value + # The `UnknownLength` sentinel is a class, not a numeric value, so the + # guard skips assignment rather than storing or comparing it. + bar.update(base.UnknownLength) + assert bar.value == 40 + assert bar.previous_value == previous + bar.finish() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..375d9074 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,262 @@ +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) + + # A stream that legitimately fails to report tty-ness (e.g. OSError on + # a real I/O object) is simply not treated as an ANSI terminal. Only the + # narrowed OSError/ValueError/AttributeError set is tolerated; unexpected + # errors propagate (covered in tests/test_env_detection.py). + def raise_error(): + raise OSError('test') + + fd.isatty = raise_error + assert not progressbar.env.is_ansi_terminal(fd) + + +@pytest.mark.parametrize( + 'value,expected', + [ + ('', ''), + (b'', b''), + ('\x1b[31m', ''), + (b'\x1b[31m', b''), + ('\x1b[1m\x1b[31mtext\x1b[0m', 'text'), + (b'\x1b[1m\x1b[31mtext\x1b[0m', b'text'), + ('\x1b[38;5;208mhello world\x1b[0m', 'hello world'), + ], +) +def test_no_color(value, expected) -> None: + assert progressbar.utils.no_color(value) == expected + + +def test_no_color_type_error() -> None: + with pytest.raises(TypeError): + progressbar.utils.no_color(123) + + +@pytest.mark.parametrize( + 'value,expected', + [ + ('', 0), + (b'', 0), + ('\x1b[31m', 0), + ('\x1b[1m\x1b[31mtext\x1b[0m', 4), + ('\x1b[38;5;208mhello world\x1b[0m', 11), + ], +) +def test_len_color(value, expected) -> None: + assert progressbar.utils.len_color(value) == expected + + +def test_attribute_dict_empty() -> None: + attrs = progressbar.utils.AttributeDict() + assert len(attrs) == 0 + with pytest.raises(AttributeError): + _ = attrs.missing + + +def test_attribute_dict_set_get_del() -> None: + attrs = progressbar.utils.AttributeDict() + attrs.spam = 123 + assert attrs['spam'] == 123 + assert attrs.spam == 123 + del attrs.spam + with pytest.raises(AttributeError): + _ = attrs.spam + with pytest.raises(AttributeError): + del attrs.spam + + +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..03eb3a9b --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,428 @@ +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: + # F4: user-facing validation must raise ValueError (not a bare assert that + # vanishes under ``python -O``). + with pytest.raises(ValueError): + progressbar.widgets.create_wrapper('ab') + + with pytest.raises(RuntimeError): + progressbar.widgets.create_wrapper(123) + + +def test_create_marker_rejects_multichar_marker() -> None: + # F4: markers must be a single visible character. + with pytest.raises(ValueError): + progressbar.widgets.create_marker('ab') + + +def test_multi_range_bar_rejects_multichar_marker() -> None: + # F4: the render path validates marker width; a 2-char marker must raise + # ValueError rather than a stripped-under-O assert. + widget = progressbar.MultiRangeBar('amounts', markers=['ab', ' ']) + bar = progressbar.ProgressBar( + widgets=[widget], + variables={'amounts': []}, + max_value=10, + fd=io.StringIO(), + term_width=60, + ) + bar.start() + data = bar.data() + data['variables'] = {'amounts': [1, 0]} + with pytest.raises(ValueError): + widget(bar, data, width=20) + bar.finish(dirty=True) + + +def test_multi_range_bar_rejects_multichar_fill() -> None: + # Item 4: the fill path validates the fill width; a 2-char fill must raise + # ValueError rather than a stripped-under-O assert. Non-empty amounts keep + # the initial render on the marker branch; emptying them forces the + # zero-sum ``else`` (fill) branch on the direct call. + widget = progressbar.MultiRangeBar( + 'amounts', markers=[' ', '#'], fill='xx' + ) + bar = progressbar.ProgressBar( + widgets=[widget], + variables={'amounts': [1, 0]}, + max_value=10, + fd=io.StringIO(), + term_width=60, + ) + bar.start() + data = bar.data() + data['variables'] = {'amounts': []} + with pytest.raises(ValueError): + widget(bar, data, width=20) + bar.finish(dirty=True) + + +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_postfix_widget_renders_mapping_sorted() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': {'z': 2, 'a': 1}}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == ' a=1, z=2' + + +def test_postfix_widget_renders_string() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': 'loss=0.25'}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == ' loss=0.25' + + +def test_postfix_widget_renders_other_values() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': 3}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == ' 3' + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + (0, ' 0'), + (0.0, ' 0.0'), + (False, ' False'), + ], +) +def test_postfix_widget_renders_falsy_values(value, expected) -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': value}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == expected + + +def test_postfix_widget_omits_empty_values() -> None: + bar = progressbar.ProgressBar( + widgets=[progressbar.Postfix()], + variables={'postfix': None}, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + output = ''.join(bar._format_widgets()) + assert output == '' + + +def test_format_unit_value_special_cases() -> None: + assert progressbar.widgets.format_unit_value(None) == 'N/A' + unknown_value = progressbar.widgets.format_unit_value( + progressbar.UnknownLength, + ) + assert unknown_value == 'N/A' + assert progressbar.widgets.format_unit_value(1.5, unit='B') == '1.5 B' + assert progressbar.widgets.format_unit_value(2, unit='B') == '2 B' + + +def test_unit_progress_scales_values() -> None: + bar = progressbar.ProgressBar( + max_value=2048, + widgets=[progressbar.UnitProgress(unit='B', unit_scale=True)], + fd=io.StringIO(), + term_width=80, + ) + bar.start() + bar.update(1024, force=True) + output = ''.join(bar._format_widgets()) + assert output == '1.0 KiB of 2.0 KiB' + + +def test_unit_progress_uses_progress_units_by_default() -> None: + bar = progressbar.ProgressBar( + max_value=2048, + widgets=[progressbar.UnitProgress()], + unit='B', + unit_scale=True, + fd=io.StringIO(), + term_width=80, + ) + bar.start() + bar.update(1024, force=True) + output = ''.join(bar._format_widgets()) + assert output == '1.0 KiB of 2.0 KiB' + + +def test_format_widget() -> None: + widgets = [ + progressbar.FormatLabel(f'%({mapping})r') + 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/unknownlength.py b/tests/unknownlength.py deleted file mode 100644 index 83659aae..00000000 --- a/tests/unknownlength.py +++ /dev/null @@ -1,27 +0,0 @@ -import progressbar -from progressbar import ProgressBar, UnknownLength - - -def test_unknown_length(): - pb = ProgressBar(widgets=[progressbar.AnimatedMarker()], - max_value=UnknownLength) - assert pb.max_value is UnknownLength - - -def test_unknown_length_default_widgets(): - # The default widgets picked should work without a known max_value - pb = ProgressBar(max_value=UnknownLength).start() - for i in range(60): - pb.update(i) - pb.finish() - - -def test_unknown_length_at_start(): - # The default widgets should be picked after we call .start() - pb = ProgressBar().start(max_value=UnknownLength) - for i in range(60): - pb.update(i) - pb.finish() - - pb2 = ProgressBar().start(max_value=UnknownLength) - assert any([isinstance(w, progressbar.Bar) for w in pb2.widgets]) diff --git a/tests/widgets.py b/tests/widgets.py deleted file mode 100644 index 668506cf..00000000 --- a/tests/widgets.py +++ /dev/null @@ -1,104 +0,0 @@ -import time -import progressbar - - -def test_widgets_small_values(): - 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(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.AbsoluteETA(), - ' ', - 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.AbsoluteETA(), - progressbar.DataSize(), - 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.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(), - ] - 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/tools/README.md b/tools/README.md new file mode 100644 index 00000000..bf3d3f76 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,17 @@ +# Developer tools + +Maintenance scripts that are not shipped as part of the package. + +## `generate_colors.py` + +Regenerates the 256-colour table in `progressbar/terminal/colors.py`, +deriving every `HSL` value from its `RGB` via `HSL.from_rgb` (the RGB, xterm +index, colour name and Python binding name are authoritative and left +untouched). Run it in place: + +```console +python tools/generate_colors.py progressbar/terminal/colors.py +``` + +It is idempotent; `--check` verifies the committed file is up to date +without writing. diff --git a/tools/generate_colors.py b/tools/generate_colors.py new file mode 100644 index 00000000..454e2340 --- /dev/null +++ b/tools/generate_colors.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Regenerate ``progressbar/terminal/colors.py`` with canonical HSL values. + +The 256-colour table is *data*, not logic. For every entry the RGB triple, +the xterm index, the canonical colour name and the Python binding name are +authoritative and must never change: importers reference the binding names +directly, including the deliberate last-wins duplicates (``blue3``, +``deep_sky_blue4``, ...). Only the HSL triple is *derived*, and it is +derived here from the RGB via :meth:`progressbar.terminal.base.HSL.from_rgb` +so the stored value can never drift away from the RGB again. + +The hand-entered HSL column had corrupted rows -- for example +``DeepSkyBlue4`` (``#005f87``) stored hue ``97`` where the real hue is +``198`` -- which made every gradient that interpolated through those +colours blend through the wrong hue. Recomputing from RGB fixes the data at +its source. + +Usage (rewrites the file in place):: + + python tools/generate_colors.py progressbar/terminal/colors.py + +The generator parses the *current* file to recover the ordered +``(binding, RGB, name, xterm)`` tuples, so name/order fidelity is +guaranteed however the file happens to be reflowed. It is idempotent: +running it twice produces a byte-identical file, because the HSL column is +always recomputed from RGB and never read back. Pass ``--check`` to verify +that without writing (exit status 1 if the file is stale). +""" + +from __future__ import annotations + +import argparse +import ast +import pathlib +import sys +import typing + +# Import the real HSL/RGB so the generated values match runtime exactly. +_REPO_ROOT: pathlib.Path = pathlib.Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from progressbar.terminal.base import HSL, RGB # noqa: E402 + +#: Maximum line length (matches ``ruff.toml``); longer calls are wrapped one +#: argument per line, exactly as ``ruff format`` would. +LINE_LENGTH: int = 79 + + +class Entry(typing.NamedTuple): + """One authoritative colour-table row; HSL is derived, not stored here.""" + + binding: str + rgb: RGB + name: str + xterm: int + + +def _int_constant(node: ast.expr) -> int: + if not isinstance(node, ast.Constant) or not isinstance(node.value, int): + raise TypeError(f'expected an int literal, got {ast.dump(node)}') + return node.value + + +def _str_constant(node: ast.expr) -> str: + if not isinstance(node, ast.Constant) or not isinstance(node.value, str): + raise TypeError(f'expected a str literal, got {ast.dump(node)}') + return node.value + + +def _is_register_call(node: ast.stmt) -> bool: + return ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Attribute) + and node.value.func.attr == 'register' + and isinstance(node.value.func.value, ast.Name) + and node.value.func.value.id == 'Colors' + ) + + +def _parse_rgb(node: ast.expr) -> RGB: + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == 'RGB' + and len(node.args) == 3 + ): + raise ValueError(f'expected an RGB(...) call, got {ast.dump(node)}') + return RGB(*(_int_constant(arg) for arg in node.args)) + + +def parse_entries(source: str) -> tuple[list[Entry], str, str]: + """Extract the ordered colour entries plus the file header and footer. + + The header is everything before the first ``Colors.register`` assignment + and the footer everything after the last one; both are copied verbatim so + the non-generated aliases and gradients survive untouched. + """ + tree = ast.parse(source) + register_nodes = [ + typing.cast('ast.Assign', node) + for node in tree.body + if _is_register_call(node) + ] + if not register_nodes: + raise ValueError('no Colors.register(...) assignments found') + + first, last = register_nodes[0], register_nodes[-1] + # The register block must be one contiguous run; a stray statement in the + # middle would be silently dropped by the header/footer split below. + for node in tree.body: + if ( + first.lineno <= node.lineno <= last.lineno + and not _is_register_call(node) + ): + raise ValueError( + f'unexpected non-register statement at line {node.lineno}', + ) + + entries: list[Entry] = [] + for node in register_nodes: + call = typing.cast('ast.Call', node.value) + if len(call.args) != 4 or call.keywords: + raise ValueError( + f'unexpected register signature at line {node.lineno}', + ) + target = typing.cast('ast.Name', node.targets[0]) + entries.append( + Entry( + binding=target.id, + rgb=_parse_rgb(call.args[0]), + name=_str_constant(call.args[2]), + xterm=_int_constant(call.args[3]), + ), + ) + + lines = source.splitlines(keepends=True) + header = ''.join(lines[: first.lineno - 1]) + footer = ''.join(lines[last.end_lineno :]) + return entries, header, footer + + +def render_entry(entry: Entry) -> str: + """Render one register call, wrapping only when it exceeds the limit.""" + hsl = HSL.from_rgb(entry.rgb) + rgb_src = f'RGB({entry.rgb.red}, {entry.rgb.green}, {entry.rgb.blue})' + # ``from_rgb`` rounds to ints; render them as ints (no trailing ``.0``). + hsl_src = ( + f'HSL({int(hsl.hue)}, {int(hsl.saturation)}, {int(hsl.lightness)})' + ) + args = f'{rgb_src}, {hsl_src}, {entry.name!r}, {entry.xterm}' + single = f'{entry.binding} = Colors.register({args})' + if len(single) <= LINE_LENGTH: + return single + '\n' + + # Black/ruff style: one argument per line with a magic trailing comma. + return ( + f'{entry.binding} = Colors.register(\n' + f' {rgb_src},\n' + f' {hsl_src},\n' + f' {entry.name!r},\n' + f' {entry.xterm},\n' + ')\n' + ) + + +def render(source: str) -> str: + entries, header, footer = parse_entries(source) + body = ''.join(render_entry(entry) for entry in entries) + return header + body + footer + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + 'path', + type=pathlib.Path, + help='colors.py to regenerate in place', + ) + parser.add_argument( + '--check', + action='store_true', + help='do not write; exit 1 if the file would change', + ) + args = parser.parse_args(argv) + + source = args.path.read_text() + generated = render(source) + + if args.check: + if generated != source: + sys.stderr.write( + f'{args.path} is out of date; run generate_colors.py\n', + ) + return 1 + return 0 + + if generated != source: + args.path.write_text(generated) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tox.ini b/tox.ini index 85823943..12c7e62d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,56 @@ [tox] -envlist = py26, py27, pypy, flake8, py33, py34, py35, pypy3 +envlist = + py310 + py311 + py312 + py313 + py314 + py315 + docs + ruff + codespell +; mypy is intentionally excluded: 11 non-gating precision errors are +; documented (see PR 4); it stays a local-only convenience env. skip_missing_interpreters = True [testenv] -deps = -rtests/requirements.txt +deps = + -r{toxinidir}/tests/requirements.txt + pyright +commands = + pyright + py.test --cov progressbar --cov-report=html --cov-report=term-missing --cov-report=xml --cov-config=./pyproject.toml --no-cov-on-fail --basetemp="{envtmpdir}" --confcutdir=.. {posargs} +skip_install = true -commands = py.test - -[testenv:flake8] -deps = flake8 -commands = flake8 --ignore=W391 progressbar tests +[testenv:mypy] +changedir = +basepython = python3 +deps = mypy +commands = mypy {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 +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 --check +deps = ruff +skip_install = true + +[testenv:codespell] +changedir = {toxinidir} +commands = codespell . +deps = codespell +skip_install = true diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..5a7c179e --- /dev/null +++ b/uv.lock @@ -0,0 +1,1149 @@ +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 = "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" }, +] +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 = "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 = "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 = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[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 = "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'" }, +] +fast = [ + { name = "speedups" }, +] +tests = [ + { name = "dill" }, + { name = "freezegun" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "progressbar2", extra = ["docs", "tests"] }, +] + +[package.metadata] +requires-dist = [ + { name = "dill", marker = "extra == 'tests'", specifier = ">=0.3.6" }, + { 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 = "python-utils", specifier = ">=3.8.1" }, + { name = "pywin32", marker = "sys_platform == 'win32' and extra == 'tests'" }, + { name = "speedups", marker = "extra == 'fast'", specifier = ">=2.1.0" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=1.8.5" }, + { name = "sphinx-autodoc-typehints", marker = "extra == 'docs'", specifier = ">=1.6.0" }, +] +provides-extras = ["fast", "docs", "tests"] + +[package.metadata.requires-dev] +dev = [{ name = "progressbar2", extras = ["docs", "tests"] }] + +[[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 = "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 = "speedups" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/8c/1c30a110cdbb54d2618e69ec602530dbaa19ff8f9c59ae1d21fb69a2bcae/speedups-2.1.0.tar.gz", hash = "sha256:5f8cf36328545fe43a1331c98b8054b7fe50977baa2d1e8bf69e42100acb2bb0", size = 282060, upload-time = "2026-04-10T10:10:56.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/5d/c47d8f25dc04e4a26d2cf803d381b32669c5844e75a681dc1340a3a7ca74/speedups-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e996e37a9ba42908a171a015843782fd5b70bbb86c0d794d3b993385d9ae92d", size = 401404, upload-time = "2026-04-10T10:10:29.71Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/6efda35a42cf199fef2cf894ec14dd6631ac176087404f7576a1c1625300/speedups-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c887be8c02586a4adc977f6215b4d95e3b629c8eed373550b5b4240f15dcc1", size = 443732, upload-time = "2026-04-10T10:10:31.503Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/738162bf48fd6ff264a4982ef1f9366515ac9cccd6daae1fdc8ce4158721/speedups-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fa0e47bf437d443f8b375ce10a9fb482b76d372f1d38983ee517dc182c3e22ee", size = 388565, upload-time = "2026-04-10T10:10:33.102Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/10b5288796aca6539c34fd6444718f0b6d7d27f38b3890d39fe3cb0a9571/speedups-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:216ad20c7144f6b67a440aa0b9340dafc2b8cff114f7d2a8be0e0859f16524a3", size = 397896, upload-time = "2026-04-10T10:10:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/82/ca/21b2c2849306ccee58b0cc12d1bb60b34ccb786b5ec4ca1c36258ce5c4cb/speedups-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8d3731e8222d830dd6a676642cf506e843de1efb0a0d524e3a8c36befa51906", size = 434900, upload-time = "2026-04-10T10:10:35.758Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/45ebffbadd9f5196b0891302a3d2773b2ce922807eac93c2ef53689b98d6/speedups-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cda8ed554d603a88e14317c4e4bb264b4575da830fbace682af07aece84517d", size = 388298, upload-time = "2026-04-10T10:10:37.171Z" }, + { url = "https://files.pythonhosted.org/packages/a7/69/7f4584da69981171830ef4f1efb60220c5842637d504f44b7cc17a79ee7d/speedups-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d84b9dc279081022e3ee9bb9ecaac6c9256f31c33abe4692a804887460a28b1f", size = 398872, upload-time = "2026-04-10T10:10:38.875Z" }, + { url = "https://files.pythonhosted.org/packages/d8/24/4ab0884f47dbdc7bcffdbb53ced365766574b5eb93640846bf6d5cec91b7/speedups-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03aff6b1c4b7d24cdc166c21df7361abe77b5901e3bd13274882bc8e8851fc09", size = 435662, upload-time = "2026-04-10T10:10:40.21Z" }, + { url = "https://files.pythonhosted.org/packages/45/43/65165c7c419c9174c8f10c3951002d3edc24f2c71890e36f6003929828b8/speedups-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd87256281aadb1dc583adfcea2cd51d9eadd6c2136584c11f75cbb79b0d2c86", size = 389846, upload-time = "2026-04-10T10:10:41.562Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/dcb96fc9e5953ef22a24fc19accae3a06d989306aa5dcff180add12f1fc9/speedups-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e09fb6b3f2580016f876a31f3eff1c7cca0650de37076f20a4577722722daa69", size = 399863, upload-time = "2026-04-10T10:10:42.98Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/2af199f7e71c2561a081fa18a3b19a7ef4821e4da67ab6ff0dd7cf427255/speedups-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97f0f4c042fd8726e316741a028881f50a8a0239ae4c87aec94a61057912f647", size = 435773, upload-time = "2026-04-10T10:10:44.363Z" }, + { url = "https://files.pythonhosted.org/packages/ef/68/73ff974abb6c44e9ae76744f18f2d527910bc929b0d9d4e0486b68a1bde5/speedups-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:d37ca0df57e4ea1b672bc7ba499f93583497f55f781c0b1f85ffa355c77b316d", size = 389441, upload-time = "2026-04-10T10:10:45.63Z" }, + { url = "https://files.pythonhosted.org/packages/c2/97/d67b256c45b6837164740ded2e178f6a82b1eb355563ba7e46ba6230afe3/speedups-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29f01613d0a7f4dc61b5af16f2a6242272180146a103cbab452da2e09707c715", size = 404656, upload-time = "2026-04-10T10:10:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/01/d1/1d13fa48821a4e5519bea51951843e7f6987c1dc6ac6fde17ceecf950a12/speedups-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83bff7896cc00dfe4640d6c1b420cbab1d8c6f437f6bf3890d5d13c68164b51d", size = 440517, upload-time = "2026-04-10T10:10:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e4/ac2a8681207870e532f96e9619015487113987dc5b2e9bdebd7cae7417d8/speedups-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc9fe28dfccd1c5fbbaf745ab1a17cfbf398e5f1f86dd0f0928e02b35919c965", size = 394546, upload-time = "2026-04-10T10:10:50.609Z" }, + { url = "https://files.pythonhosted.org/packages/10/ef/232571169ce2a6f74ceca407970481aa3d87e3c099217399e2e9f94f12ad/speedups-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69c0c4815d4496e154c89d84a7091d8b0296d9774602f98106a6a186404ca2d1", size = 414339, upload-time = "2026-04-10T10:10:52.331Z" }, + { url = "https://files.pythonhosted.org/packages/a3/91/84be65dcf2130c479a9c5808933091b758fc8d51618ffb15f2ed7c80910e/speedups-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33c9ec5db28d8361da49c9642409b33e8912298348f7ad1cac0d21825694db9c", size = 445293, upload-time = "2026-04-10T10:10:53.786Z" }, + { url = "https://files.pythonhosted.org/packages/e1/93/bd2cff9baf07a930619d5d4ad77142b2792ece74bb84d1a322efcf6ec7f9/speedups-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:57d604e921e04e128ea8d50c2e52cf5301ad3de533e79662a30b3a545865bef6", size = 416070, upload-time = "2026-04-10T10:10:55.051Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, +] +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" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/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" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/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" } }, +] +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" } }, +] +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" } }, +] +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" }, +]