diff --git a/ISSUE_TEMPLATE b/.github/ISSUE_TEMPLATE.md
similarity index 99%
rename from ISSUE_TEMPLATE
rename to .github/ISSUE_TEMPLATE.md
index ac4e32d4..856248df 100644
--- a/ISSUE_TEMPLATE
+++ b/.github/ISSUE_TEMPLATE.md
@@ -12,4 +12,3 @@ If applicable, code to reproduce the issue and/or the stacktrace of the issue
- 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/workflows/codeql.yml b/.github/workflows/codeql.yml
index 44ccfb21..b634a35f 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -20,23 +20,23 @@ jobs:
strategy:
fail-fast: false
matrix:
- language: [ python, javascript ]
+ language: [ python ]
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v6
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
- if: ${{ matrix.language == 'python' || matrix.language == 'javascript' }}
+ uses: github/codeql-action/autobuild@v3
+ if: ${{ matrix.language == 'python' }}
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index ddac4b2a..baa93cfe 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -2,25 +2,107 @@ 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:
- python-version: ['3.8', '3.9', '3.10', '3.11']
+ 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@v3
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
- with:
- python-version: ${{ matrix.python-version }}
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip tox
- - name: Test with tox
- run: tox
+ - 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
index 5c47a9d7..eb76940c 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -8,10 +8,12 @@ on:
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
index e226ea93..1d38c796 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,13 +2,15 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v2.4.0
+ rev: v6.0.0
hooks:
+ - id: end-of-file-fixer
- id: trailing-whitespace
- id: check-yaml
- id: check-added-large-files
-- repo: https://gitlab.com/pycqa/flake8
- rev: 3.7.9
+- repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.15.20
hooks:
- - id: flake8
+ - id: ruff-check
+ - id: ruff-format
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/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 3aa38b88..6e24af25 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -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`_::
@@ -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 38887b7c..06ba3899 100644
--- a/LICENSE
+++ b/LICENSE
@@ -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 f387924e..e44ef18a 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,12 +1,8 @@
recursive-exclude *.pyc
recursive-exclude *.pyo
-recursive-exclude *.html
-include AUTHORS.rst
include CHANGES.rst
include CONTRIBUTING.rst
include LICENSE
include README.rst
include examples.py
-include requirements.txt
-include Makefile
include pytest.ini
diff --git a/README.rst b/README.rst
index ec4f7b9a..8301b6a1 100644
--- a/README.rst
+++ b/README.rst
@@ -1,353 +1,175 @@
##############################################################################
-Text progress bar library for Python.
+progressbar2
##############################################################################
-Build status:
+A mature, typed terminal progress bar library for Python scripts that need
+custom widgets, clean output around prints and logs, multiple concurrent bars,
+unknown-length progress, and pipe-friendly CLI usage.
.. image:: https://github.com/WoLpH/python-progressbar/actions/workflows/main.yml/badge.svg
- :alt: python-progressbar test status
+ :alt: python-progressbar test status
:target: https://github.com/WoLpH/python-progressbar/actions
-Coverage:
-
.. image:: https://coveralls.io/repos/WoLpH/python-progressbar/badge.svg?branch=master
- :target: https://coveralls.io/r/WoLpH/python-progressbar?branch=master
+ :alt: coverage status
+ :target: https://coveralls.io/r/WoLpH/python-progressbar?branch=master
-******************************************************************************
Install
-******************************************************************************
+==============================================================================
-The package can be installed through `pip` (this is the recommended method):
+.. code:: sh
pip install progressbar2
-Or if `pip` is not available, `easy_install` should work as well:
-
- easy_install progressbar2
-
-Or download the latest release from Pypi (https://pypi.python.org/pypi/progressbar2) or Github.
-
-Note that the releases on Pypi are signed with my GPG key (https://pgp.mit.edu/pks/lookup?op=vindex&search=0xE81444E9CE1F695D) and can be checked using GPG:
-
- gpg --verify progressbar2-.tar.gz.asc progressbar2-.tar.gz
-
-******************************************************************************
-Introduction
-******************************************************************************
-
-A text progress bar is typically used to display the progress of a long
-running operation, providing a visual cue that processing is underway.
-
-The progressbar is based on the old Python progressbar package that was published on the now defunct Google Code. Since that project was completely abandoned by its developer and the developer did not respond to email, I decided to fork the package. This package is still backwards compatible with the original progressbar package so you can safely use it as a drop-in replacement for existing project.
-
-The ProgressBar class manages the current progress, and the format of the line
-is given by a number of widgets. A widget is an object that may display
-differently depending on the state of the progress bar. There are many types
-of widgets:
-
- - `AbsoluteETA `_
- - `AdaptiveETA `_
- - `AdaptiveTransferSpeed `_
- - `AnimatedMarker `_
- - `Bar `_
- - `BouncingBar `_
- - `Counter `_
- - `CurrentTime `_
- - `DataSize `_
- - `DynamicMessage `_
- - `ETA `_
- - `FileTransferSpeed `_
- - `FormatCustomText `_
- - `FormatLabel `_
- - `FormatLabelBar `_
- - `GranularBar `_
- - `Percentage `_
- - `PercentageLabelBar `_
- - `ReverseBar `_
- - `RotatingMarker `_
- - `SimpleProgress `_
- - `Timer `_
-
-The progressbar module is very easy to use, yet very powerful. It will also
-automatically enable features like auto-resizing when the system supports it.
-
-******************************************************************************
-Known issues
-******************************************************************************
-
-- The Jetbrains (PyCharm, etc) editors work out of the box, but for more advanced features such as the `MultiBar` support you will need to enable the "Enable terminal in output console" checkbox in the Run dialog.
-- The IDLE editor doesn't support these types of progress bars at all: https://bugs.python.org/issue23220
-- Jupyter notebooks buffer `sys.stdout` which can cause mixed output. This issue can be resolved easily using: `import sys; sys.stdout.flush()`. Linked issue: https://github.com/WoLpH/python-progressbar/issues/173
-
-******************************************************************************
-Links
-******************************************************************************
-
-* Documentation
- - https://progressbar-2.readthedocs.org/en/latest/
-* Source
- - https://github.com/WoLpH/python-progressbar
-* Bug reports
- - https://github.com/WoLpH/python-progressbar/issues
-* Package homepage
- - https://pypi.python.org/pypi/progressbar2
-* My blog
- - https://w.wol.ph/
-
-******************************************************************************
-Usage
-******************************************************************************
-
-There are many ways to use Python Progressbar, you can see a few basic examples
-here but there are many more in the examples file.
-
-Wrapping an iterable
+Quick start
==============================================================================
+
.. code:: python
import time
import progressbar
- for i in progressbar.progressbar(range(100)):
+ for item in progressbar.progressbar(range(100), desc='Loading'):
time.sleep(0.02)
-Progressbars with logging
+Progress with clean logs
==============================================================================
-Progressbars with logging require `stderr` redirection _before_ the
-`StreamHandler` is initialized. To make sure the `stderr` stream has been
-redirected on time make sure to call `progressbar.streams.wrap_stderr()` before
-you initialize the `logger`.
-
-One option to force early initialization is by using the `WRAP_STDERR`
-environment variable, on Linux/Unix systems this can be done through:
-
-.. code:: sh
-
- # WRAP_STDERR=true python your_script.py
-
-If you need to flush manually while wrapping, you can do so using:
-
-.. code:: python
-
- import progressbar
-
- progressbar.streams.flush()
-
-In most cases the following will work as well, as long as you initialize the
-`StreamHandler` after the wrapping has taken place.
+.. image:: docs/_static/progressbar-hero.svg
+ :alt: progressbar2 showing clean progress output with logs
.. code:: python
+ import sys
import time
- import logging
import progressbar
- progressbar.streams.wrap_stderr()
- logging.basicConfig()
-
- for i in progressbar.progressbar(range(10)):
- logging.error('Got %d', i)
- time.sleep(0.2)
-
-Multiple (threaded) progressbars
+ with progressbar.ProgressBar(
+ total=24,
+ desc='Build',
+ fd=sys.stdout,
+ redirect_stdout=True,
+ line_breaks=False,
+ is_terminal=True,
+ enable_colors=True,
+ term_width=112,
+ ) as bar:
+ for step in range(24):
+ if step in {8, 16}:
+ print(f'log: completed step {step}')
+ bar.update(step + 1, force=True)
+ time.sleep(0.005)
+
+Multiple bars
==============================================================================
-.. code:: python
+.. image:: docs/_static/progressbar-multibar.svg
+ :alt: multiple progress bars updating together
- import random
- import threading
- import time
+.. code:: python
+ import io
+ import re
import progressbar
- BARS = 5
- N = 50
-
-
- def do_something(bar):
- for i in bar(range(N)):
- # Sleep up to 0.1 seconds
- time.sleep(random.random() * 0.1)
-
- # print messages at random intervals to show how extra output works
- if random.random() > 0.9:
- bar.print('random message for bar', bar, i)
-
-
- with progressbar.MultiBar() as multibar:
- for i in range(BARS):
- # Get a progressbar
- bar = multibar[f'Thread label here {i}']
- # Create a thread and pass the progressbar
- threading.Thread(target=do_something, args=(bar,)).start()
-
-Context wrapper
+ fd = io.StringIO()
+ multibar = progressbar.MultiBar(
+ fd=fd,
+ total=24,
+ enable_colors=True,
+ initial_format=None,
+ finished_format=None,
+ remove_finished=None,
+ sort_reverse=False,
+ term_width=112,
+ )
+ build = multibar['build']
+ test = multibar['test']
+ terminal_control_re = re.compile(r'\x1b\[[0-9;]*[A-Za-ln-z]')
+
+ def emit_frame():
+ output = terminal_control_re.sub('', fd.getvalue())
+ for line in output.split('\r'):
+ line = line.strip()
+ if line:
+ print(line)
+ print('\f', end='')
+ fd.seek(0)
+ fd.truncate(0)
+
+ multibar.render(force=True, flush=True)
+ emit_frame()
+
+ for step in range(24):
+ build.update(step + 1, force=True)
+ test_value = min(24, max(0, round((step - 3) * 1.2)))
+ test.update(test_value, force=True)
+ multibar.render(force=True, flush=True)
+ emit_frame()
+
+Unknown length and animated bars
==============================================================================
-.. code:: python
- import time
- import progressbar
+.. image:: docs/_static/progressbar-unknown-length.svg
+ :alt: unknown length progress with an animated marker
- with progressbar.ProgressBar(max_value=10) as bar:
- for i in range(10):
- time.sleep(0.1)
- bar.update(i)
-
-Combining progressbars with print output
-==============================================================================
.. code:: python
- import time
+ import sys
import progressbar
- for i in progressbar.progressbar(range(100), redirect_stdout=True):
- print('Some text', i)
- time.sleep(0.1)
-
-Progressbar with unknown length
+ with progressbar.ProgressBar(
+ max_value=progressbar.UnknownLength,
+ fd=sys.stdout,
+ line_breaks=False,
+ is_terminal=True,
+ enable_colors=True,
+ term_width=112,
+ ) as bar:
+ for value in range(0, 120, 10):
+ bar.update(value, force=True)
+
+CLI usage
==============================================================================
-.. code:: python
- import time
- import progressbar
+.. code:: sh
- bar = progressbar.ProgressBar(max_value=progressbar.UnknownLength)
- for i in range(20):
- time.sleep(0.1)
- bar.update(i)
+ progressbar --progress --timer --eta --rate --bytes input.bin -o output.bin
-Bar with custom widgets
+Feature highlights
==============================================================================
-.. code:: python
- import time
- import progressbar
+* Works as an iterable wrapper or a manually updated progress bar.
+* Supports custom widgets, colors, granular bars, animated markers, and labels.
+* Handles unknown-length iterators.
+* Supports multiple concurrent progress bars with ``MultiBar``.
+* Redirects stdout/stderr so regular output does not corrupt the active bar.
+* Includes a pipe-friendly ``progressbar`` command.
+* Ships typed package metadata.
- widgets=[
- ' [', progressbar.Timer(), '] ',
- progressbar.Bar(),
- ' (', progressbar.ETA(), ') ',
- ]
- for i in progressbar.progressbar(range(20), widgets=widgets):
- time.sleep(0.1)
-
-Bar with wide Chinese (or other multibyte) characters
+Known terminal caveats
==============================================================================
-.. code:: python
-
- # vim: fileencoding=utf-8
- import time
- import progressbar
-
+* JetBrains IDEs need "Enable terminal in output console" for advanced
+ terminal behavior such as ``MultiBar``.
+* IDLE does not support terminal progress bars.
+* Jupyter buffers stdout; call ``sys.stdout.flush()`` when output appears late.
- def custom_len(value):
- # These characters take up more space
- characters = {
- '进': 2,
- '度': 2,
- }
-
- total = 0
- for c in value:
- total += characters.get(c, 1)
-
- return total
-
-
- bar = progressbar.ProgressBar(
- widgets=[
- '进度: ',
- progressbar.Bar(),
- ' ',
- progressbar.Counter(format='%(value)02d/%(max_value)d'),
- ],
- len_func=custom_len,
- )
- for i in bar(range(10)):
- time.sleep(0.1)
-
-Showing multiple independent progress bars in parallel
+Project history
==============================================================================
-.. code:: python
-
- import random
- import sys
- import time
-
- import progressbar
-
- BARS = 5
- N = 100
-
- # Construct the list of progress bars with the `line_offset` so they draw
- # below each other
- bars = []
- for i in range(BARS):
- bars.append(
- progressbar.ProgressBar(
- max_value=N,
- # We add 1 to the line offset to account for the `print_fd`
- line_offset=i + 1,
- max_error=False,
- )
- )
-
- # Create a file descriptor for regular printing as well
- print_fd = progressbar.LineOffsetStreamWrapper(lines=0, stream=sys.stdout)
+progressbar2 is based on the old Python progressbar package that was published
+on the now defunct Google Code. Since that project was completely abandoned by
+its developer and the developer did not respond to email, I decided to fork the
+package.
- # The progress bar updates, normally you would do something useful here
- for i in range(N * BARS):
- time.sleep(0.005)
+This package is still backwards compatible with the original progressbar package
+so you can safely use it as a drop-in replacement for existing projects.
- # Increment one of the progress bars at random
- bars[random.randrange(0, BARS)].increment()
-
- # Print a status message to the `print_fd` below the progress bars
- print(f'Hi, we are at update {i+1} of {N * BARS}', file=print_fd)
-
- # Cleanup the bars
- for bar in bars:
- bar.finish()
-
- # Add a newline to make sure the next print starts on a new line
- print()
-
-******************************************************************************
-
-Naturally we can do this from separate threads as well:
-
-.. code:: python
-
- import random
- import threading
- import time
-
- import progressbar
-
- BARS = 5
- N = 100
-
- # Create the bars with the given line offset
- bars = []
- for line_offset in range(BARS):
- bars.append(progressbar.ProgressBar(line_offset=line_offset, max_value=N))
-
-
- class Worker(threading.Thread):
- def __init__(self, bar):
- super().__init__()
- self.bar = bar
-
- def run(self):
- for i in range(N):
- time.sleep(random.random() / 25)
- self.bar.update(i)
-
-
- for bar in bars:
- Worker(bar).start()
+Links
+==============================================================================
- print()
+* Documentation: https://progressbar-2.readthedocs.org/en/latest/
+* Source: https://github.com/WoLpH/python-progressbar
+* Bug reports: https://github.com/WoLpH/python-progressbar/issues
+* Package homepage: https://pypi.python.org/pypi/progressbar2
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index fa5f5a53..00000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-# What Python version is installed where:
-# http://www.appveyor.com/docs/installed-software#python
-
-environment:
- matrix:
- - PYTHON: "C:\\Python27"
- TOX_ENV: "py27"
-
- - PYTHON: "C:\\Python34"
- TOX_ENV: "py34"
-
- - PYTHON: "C:\\Python35"
- TOX_ENV: "py35"
-
- - PYTHON: "C:\\Python36"
- TOX_ENV: "py36"
-
-
-init:
- - "%PYTHON%/python -V"
- - "%PYTHON%/python -c \"import struct;print( 8 * struct.calcsize(\'P\'))\""
-
-install:
- - "%PYTHON%/Scripts/easy_install -U pip"
- - "%PYTHON%/Scripts/pip install tox"
- - "%PYTHON%/Scripts/pip install wheel"
-
-build: false # Not a C# project, build stuff at the test step instead.
-
-test_script:
- - "%PYTHON%/Scripts/tox -e %TOX_ENV%"
-
-after_test:
- - "%PYTHON%/python setup.py bdist_wheel"
- - ps: "ls dist"
-
-artifacts:
- - path: dist\*
-
-#on_success:
-# - TODO: upload the content of dist/*.whl to a public wheelhouse
diff --git a/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".
+
+
+
+## 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'')
+ 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 @@
+
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 @@
+
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 @@
+
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 58b16d58..c8ff0e92 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -25,4 +25,3 @@ 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/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 aa711793..eb2953d2 100644
--- a/examples.py
+++ b/examples.py
@@ -73,9 +73,9 @@ def do_something(bar):
bar_labels = []
for i in range(BARS):
# Get a progressbar
- bar_label = 'Bar #%d' % i
+ bar_label = f'Bar #{i:d}'
bar_labels.append(bar_label)
- multibar[bar_label]
+ assert multibar[bar_label] is not None
for _ in range(N * BARS):
time.sleep(0.005)
@@ -85,6 +85,11 @@ def do_something(bar):
# 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:
@@ -148,7 +153,7 @@ def with_example_stdout_redirection() -> None:
with progressbar.ProgressBar(max_value=10, redirect_stdout=True) as p:
for i in range(10):
if i % 3 == 0:
- print('Some print statement %i' % i)
+ print(f'Some print statement {i:d}')
# do something
p.update(i)
time.sleep(0.1)
@@ -544,8 +549,9 @@ def with_right_justify() -> None:
@example
def exceeding_maximum() -> None:
- with progressbar.ProgressBar(max_value=1) as progress, contextlib.suppress(
- ValueError
+ with (
+ progressbar.ProgressBar(max_value=1) as progress,
+ contextlib.suppress(ValueError),
):
progress.update(2)
diff --git a/progressbar/__about__.py b/progressbar/__about__.py
index 785fff86..0e870b3f 100644
--- a/progressbar/__about__.py
+++ b/progressbar/__about__.py
@@ -22,6 +22,8 @@
)
__email__ = 'wolph@wol.ph'
__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 ff76ff45..00f1c8fe 100644
--- a/progressbar/__init__.py
+++ b/progressbar/__init__.py
@@ -1,91 +1,204 @@
-from datetime import date
+"""progressbar2 public API.
-from .__about__ import __author__, __version__
-from .algorithms import (
- DoubleExponentialMovingAverage,
- ExponentialMovingAverage,
- SmoothingAlgorithm,
+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__,
)
-from .bar import DataTransferBar, NullBar, ProgressBar
-from .base import UnknownLength
-from .multi import MultiBar, SortKey
-from .shortcuts import progressbar
-from .terminal.stream import LineOffsetStreamWrapper
-from .utils import len_color, streams
-from .widgets import (
- ETA,
- AbsoluteETA,
- AdaptiveETA,
- AdaptiveTransferSpeed,
- AnimatedMarker,
- Bar,
- BouncingBar,
- Counter,
- CurrentTime,
- DataSize,
- DynamicMessage,
- FileTransferSpeed,
- FormatCustomText,
- FormatLabel,
- FormatLabelBar,
- GranularBar,
- JobStatusBar,
- MultiProgressBar,
- MultiRangeBar,
- Percentage,
- PercentageLabelBar,
- ReverseBar,
- RotatingMarker,
- SimpleProgress,
- SmoothingETA,
- Timer,
- Variable,
- VariableMixin,
+
+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',
+ }
)
-__date__ = str(date.today())
+#: 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__ = [
- 'progressbar',
- 'len_color',
- 'streams',
- 'Timer',
'ETA',
- 'AdaptiveETA',
'AbsoluteETA',
- 'SmoothingETA',
- 'SmoothingAlgorithm',
- 'ExponentialMovingAverage',
- 'DoubleExponentialMovingAverage',
- 'DataSize',
- 'FileTransferSpeed',
+ 'AdaptiveETA',
'AdaptiveTransferSpeed',
'AnimatedMarker',
- 'Counter',
- 'Percentage',
- 'FormatLabel',
- 'SimpleProgress',
'Bar',
- 'ReverseBar',
'BouncingBar',
- 'UnknownLength',
- 'ProgressBar',
+ 'Counter',
+ 'CurrentTime',
+ 'DataSize',
'DataTransferBar',
- 'RotatingMarker',
- 'VariableMixin',
- 'MultiRangeBar',
- 'MultiProgressBar',
- 'GranularBar',
- 'FormatLabelBar',
- 'PercentageLabelBar',
- 'Variable',
+ 'DoubleExponentialMovingAverage',
'DynamicMessage',
+ 'ExponentialMovingAverage',
+ 'FastProgressBar',
+ 'FileTransferSpeed',
'FormatCustomText',
- 'CurrentTime',
- 'NullBar',
- '__author__',
- '__version__',
+ 'FormatLabel',
+ 'FormatLabelBar',
+ 'GranularBar',
+ 'JobStatusBar',
'LineOffsetStreamWrapper',
'MultiBar',
+ 'MultiProgressBar',
+ 'MultiRangeBar',
+ 'NullBar',
+ 'Percentage',
+ 'PercentageLabelBar',
+ 'Postfix',
+ 'ProgressBar',
+ 'ReverseBar',
+ 'RotatingMarker',
+ 'SimpleProgress',
+ 'SmoothingAlgorithm',
+ 'SmoothingETA',
'SortKey',
- 'JobStatusBar',
+ 'Timer',
+ 'UnitProgress',
+ 'UnknownLength',
+ 'Variable',
+ 'VariableMixin',
+ '__author__',
+ '__version__',
+ 'len_color',
+ 'progressbar',
+ 'streams',
]
diff --git a/progressbar/__main__.py b/progressbar/__main__.py
index 0bfd7fb5..9523544a 100644
--- a/progressbar/__main__.py
+++ b/progressbar/__main__.py
@@ -4,6 +4,7 @@
import contextlib
import pathlib
import sys
+import time
import typing
from pathlib import Path
from typing import IO, BinaryIO, TextIO
@@ -62,14 +63,13 @@ def create_argument_parser() -> argparse.ArgumentParser:
Create the argument parser for the `progressbar` command.
"""
- parser = argparse.ArgumentParser(
- description="""
+ 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(
@@ -272,6 +272,81 @@ def create_argument_parser() -> argparse.ArgumentParser:
return parser
+def _default_widgets(
+ filesize_available: bool,
+) -> list[progressbar.widgets.WidgetBase | str]:
+ if filesize_available:
+ return [
+ progressbar.Percentage(),
+ ' ',
+ progressbar.Bar(),
+ ' ',
+ progressbar.Timer(),
+ ' ',
+ progressbar.FileTransferSpeed(),
+ ]
+ return [
+ progressbar.SimpleProgress(),
+ ' ',
+ progressbar.DataSize(),
+ ' ',
+ progressbar.Timer(),
+ ]
+
+
+def _append_widget_group(
+ widgets: list[progressbar.widgets.WidgetBase | str],
+ group: typing.Sequence[progressbar.widgets.WidgetBase | str],
+) -> None:
+ if widgets:
+ widgets.append(' ')
+ widgets.extend(group)
+
+
+def _build_widgets(
+ args: argparse.Namespace,
+ filesize_available: bool,
+) -> list[progressbar.widgets.WidgetBase | str]:
+ if args.quiet:
+ return []
+
+ requested_widgets = [
+ (args.progress, [progressbar.Percentage(), ' ', progressbar.Bar()]),
+ (args.bytes, [progressbar.DataSize()]),
+ (args.timer, [progressbar.Timer()]),
+ (args.eta, [progressbar.AdaptiveETA()]),
+ (args.fineta, [progressbar.AbsoluteETA()]),
+ (args.rate or args.average_rate, [progressbar.FileTransferSpeed()]),
+ ]
+ selected_widgets = [
+ group for selected, group in requested_widgets if selected
+ ]
+ if not selected_widgets:
+ return _default_widgets(filesize_available)
+
+ widgets: list[progressbar.widgets.WidgetBase | str] = []
+ for group in selected_widgets:
+ _append_widget_group(widgets, group)
+
+ return widgets
+
+
+def _sleep_for_rate_limit(
+ rate_limit: int | None,
+ transferred: int,
+ started_at: float,
+ now: float | None = None,
+) -> None:
+ if not rate_limit:
+ return
+ now = time.monotonic() if now is None else now
+ expected_elapsed = transferred / rate_limit
+ actual_elapsed = now - started_at
+ delay = expected_elapsed - actual_elapsed
+ if delay > 0:
+ time.sleep(delay)
+
+
def main(argv: list[str] | None = None) -> None: # noqa: C901
"""
Main function for the `progressbar` command.
@@ -317,50 +392,44 @@ def main(argv: list[str] | None = None) -> None: # noqa: C901
total_size = size_to_bytes(args.size)
filesize_available = True
- if filesize_available:
- # Create the progress bar components
- widgets = [
- progressbar.Percentage(),
- ' ',
- progressbar.Bar(),
- ' ',
- progressbar.Timer(),
- ' ',
- progressbar.FileTransferSpeed(),
- ]
- else:
- widgets = [
- progressbar.SimpleProgress(),
- ' ',
- progressbar.DataSize(),
- ' ',
- progressbar.Timer(),
- ]
-
- if args.eta:
- widgets.append(' ')
- widgets.append(progressbar.AdaptiveETA())
+ widgets = _build_widgets(args, filesize_available)
+ progress_bar_class: type[progressbar.ProgressBar] = (
+ progressbar.NullBar if args.quiet else progressbar.ProgressBar
+ )
# Initialize the progress bar
- bar = progressbar.ProgressBar(
- # widgets=widgets,
- max_value=total_size or None,
+ 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):
+ with contextlib.suppress(KeyboardInterrupt, BrokenPipeError):
for input_path in input_paths:
if isinstance(input_path, pathlib.Path):
- input_stream = stack.enter_context(
- input_path.open('r' if args.line_mode else 'rb')
- )
+ 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
@@ -375,8 +444,24 @@ def main(argv: list[str] | None = None) -> None: # noqa: C901
break
output_stream.write(data)
- total_transferred += len(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)
@@ -387,8 +472,14 @@ def _get_output_stream(
stack: contextlib.ExitStack,
) -> typing.IO[typing.Any]:
if output and output != '-':
- mode = 'w' if line_mode else 'wb'
- return stack.enter_context(open(output, mode)) # noqa: SIM115
+ 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:
diff --git a/progressbar/algorithms.py b/progressbar/algorithms.py
index c0cb7a1f..8dd2cf89 100644
--- a/progressbar/algorithms.py
+++ b/progressbar/algorithms.py
@@ -27,10 +27,15 @@ class ExponentialMovingAverage(SmoothingAlgorithm):
def __init__(self, alpha: float = 0.5) -> None:
self.alpha = alpha
- self.value = 0
+ self.value: float | None = None
def update(self, new_value: float, elapsed: timedelta) -> float:
- self.value = self.alpha * new_value + (1 - self.alpha) * self.value
+ 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
@@ -43,10 +48,15 @@ class DoubleExponentialMovingAverage(SmoothingAlgorithm):
def __init__(self, alpha: float = 0.5) -> None:
self.alpha = alpha
- self.ema1 = 0
- self.ema2 = 0
+ self.ema1: float | None = None
+ self.ema2: float | None = None
def update(self, new_value: float, elapsed: timedelta) -> float:
- self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1
- self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2
+ 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 3a5666e6..7cf1622d 100644
--- a/progressbar/bar.py
+++ b/progressbar/bar.py
@@ -1,7 +1,10 @@
from __future__ import annotations
import abc
+import collections.abc
import contextlib
+import functools
+import importlib
import itertools
import logging
import math
@@ -11,11 +14,12 @@
import timeit
import typing
import warnings
+import weakref
from copy import deepcopy
from datetime import datetime
from types import FrameType
-from python_utils import converters, types
+from python_utils import converters
import progressbar.env
import progressbar.terminal
@@ -24,52 +28,79 @@
from . import (
base,
utils,
- widgets,
- widgets as widgets_module, # Avoid name collision
)
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 = typing.Union[NumberT, typing.Type[base.UnknownLength], None]
+ValueT = NumberT | type[base.UnknownLength] | None
-T = types.TypeVar('T')
+T = typing.TypeVar('T')
class ProgressBarMixinBase(abc.ABC):
_started = False
_finished = False
- _last_update_time: types.Optional[float] = None
+ _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()`
- widgets: types.MutableSequence[widgets_module.WidgetBase | str]
+ #: (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: types.Optional[str]
+ prefix: str | None
#: Suffix the progressbar with the given string
- suffix: types.Optional[str]
+ 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: types.Dict[str, types.Any]
- #: Custom length function for multibyte characters such as CJK
- # mypy and pyright can't agree on what the correct one is... so we'll
- # need to use a helper function :(
- # custom_len: types.Callable[['ProgressBarMixinBase', str], int]
- custom_len: types.Callable[[str], int]
+ 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: types.Optional[datetime]
+ initial_start_time: datetime | None
#: The interval to poll for updates in seconds if there are updates
- poll_interval: types.Optional[float]
+ poll_interval: float | None
#: The minimum interval to poll for updates in seconds even if there are
#: no updates
min_poll_interval: float
@@ -84,7 +115,10 @@ class ProgressBarMixinBase(abc.ABC):
#: Current progress (min_value <= value <= max_value)
value: NumberT
#: Previous progress value
- previous_value: types.Optional[NumberT]
+ 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
@@ -92,24 +126,24 @@ class ProgressBarMixinBase(abc.ABC):
max_value: ValueT
#: The time the progressbar reached `max_value` or when `finish()` was
#: called.
- end_time: types.Optional[datetime]
+ end_time: datetime | None
#: The time `start()` was called or iteration started.
- start_time: types.Optional[datetime]
+ 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: types.Dict[str, types.Any]
+ extra: dict[str, typing.Any]
- def get_last_update_time(self) -> types.Optional[datetime]:
+ def get_last_update_time(self) -> datetime | None:
if self._last_update_time:
return datetime.fromtimestamp(self._last_update_time)
else:
return None
- def set_last_update_time(self, value: types.Optional[datetime]):
+ def set_last_update_time(self, value: datetime | None):
if value:
self._last_update_time = time.mktime(value.timetuple())
else:
@@ -132,16 +166,18 @@ def finish(self): # pragma: no cover
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.
+ # 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 AttributeError:
+ except Exception: # noqa: BLE001, S110
pass
def __getstate__(self):
return self.__dict__
- def data(self) -> types.Dict[str, types.Any]: # pragma: no cover
+ def data(self) -> dict[str, typing.Any]: # pragma: no cover
raise NotImplementedError()
def started(self) -> bool:
@@ -151,13 +187,19 @@ def finished(self) -> bool:
return self._finished
-class ProgressBarBase(types.Iterable[NumberT], ProgressBarMixinBase):
+class ProgressBarBase(collections.abc.Iterable[NumberT], ProgressBarMixinBase):
_index_counter = itertools.count()
index: int = -1
label: str = ''
def __init__(self, **kwargs: typing.Any):
- self.index = next(self._index_counter)
+ # 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)
def __repr__(self):
@@ -295,15 +337,15 @@ def _determine_enable_colors(
return color_support
- def print(self, *args: types.Any, **kwargs: types.Any) -> None:
+ 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()
+ super().start(**kwargs)
- def update(self, *args: types.Any, **kwargs: types.Any) -> None:
- ProgressBarMixinBase.update(self, *args, **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:
@@ -314,12 +356,15 @@ def update(self, *args: types.Any, **kwargs: types.Any) -> None:
try: # pragma: no cover
self.fd.write(line)
except UnicodeEncodeError: # pragma: no cover
- self.fd.write(types.cast(str, line.encode('ascii', 'replace')))
+ # ``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: types.Any,
- **kwargs: types.Any,
+ *args: typing.Any,
+ **kwargs: typing.Any,
) -> None: # pragma: no cover
os_specific.reset_console_mode()
@@ -327,7 +372,7 @@ def finish(
return
end = kwargs.pop('end', '\n')
- ProgressBarMixinBase.finish(self, *args, **kwargs)
+ super().finish(*args, **kwargs)
if end and not self.line_breaks:
self.fd.write(end)
@@ -344,6 +389,8 @@ def _format_line(self):
return widgets.rjust(self.term_width)
def _format_widgets(self):
+ widgets = _load_widgets()
+
result = []
expanding = []
width = self.term_width
@@ -368,7 +415,7 @@ def _format_widgets(self):
count = len(expanding)
while expanding:
- portion = max(int(math.ceil(width * 1.0 / count)), 0)
+ portion = max(math.ceil(width / count), 0)
index = expanding.pop()
widget = result[index]
count -= 1
@@ -385,9 +432,65 @@ def _to_unicode(cls, args: typing.Any):
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):
- ProgressBarMixinBase.__init__(self, **kwargs)
+ super().__init__(**kwargs)
self.signal_set = False
if term_width:
@@ -395,39 +498,39 @@ def __init__(self, term_width: int | None = None, **kwargs: typing.Any):
else: # pragma: no cover
with contextlib.suppress(Exception):
self._handle_resize()
- import signal
-
- self._prev_handle = signal.getsignal(
- signal.SIGWINCH # type: ignore
- )
- signal.signal(
- signal.SIGWINCH,
- self._handle_resize, # type: ignore
- )
+ _ResizeRegistry.install(self)
self.signal_set = True
def _handle_resize(
self, signum: int | None = None, frame: None | FrameType = None
):
"Tries to catch resize signals sent from the terminal."
- w, h = utils.get_terminal_size()
+ w, _h = utils.get_terminal_size()
self.term_width = w
def finish(self): # pragma: no cover
- ProgressBarMixinBase.finish(self)
+ super().finish()
if self.signal_set:
with contextlib.suppress(Exception):
- import signal
-
- signal.signal(
- signal.SIGWINCH,
- self._prev_handle, # type: ignore
- )
+ _ResizeRegistry.uninstall(self)
+ self.signal_set = False
class StdRedirectMixin(DefaultFdMixin):
+ """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]
@@ -437,11 +540,14 @@ def __init__(
self,
redirect_stderr: bool = False,
redirect_stdout: bool = False,
+ redirect_blank_line: bool = False,
**kwargs,
):
- DefaultFdMixin.__init__(self, **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
@@ -459,23 +565,32 @@ def start(self, *args: typing.Any, **kwargs: typing.Any):
self.stderr = utils.streams.stderr
utils.streams.start_capturing(self)
- DefaultFdMixin.start(self, *args, **kwargs)
+ super().start(*args, **kwargs)
- def update(self, value: types.Optional[NumberT] = None):
- if not self.line_breaks and utils.streams.needs_clear():
+ 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')
utils.streams.flush()
- DefaultFdMixin.update(self, value=value)
+ 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)
def finish(self, end='\n'):
- DefaultFdMixin.finish(self, end=end)
- utils.streams.stop_capturing(self)
- if self.redirect_stdout:
- utils.streams.unwrap_stdout()
+ 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()
+ if self.redirect_stderr:
+ utils.streams.unwrap_stderr()
class ProgressBar(
@@ -554,37 +669,96 @@ class ProgressBar(
you from changing the ProgressBar you should treat it as read only.
"""
- _iterable: types.Optional[types.Iterator]
+ _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: types.Optional[float] = None
+ _last_update_time: float | None = None
paused: bool = False
def __init__(
self,
min_value: NumberT = 0,
max_value: ValueT = None,
- widgets: types.Optional[
- types.Sequence[widgets_module.WidgetBase | str]
- ] = None,
+ widgets: collections.abc.Sequence[typing.Any] | None = None,
left_justify: bool = True,
initial_value: NumberT = 0,
- poll_interval: types.Optional[float] = None,
- widget_kwargs: types.Optional[types.Dict[str, types.Any]] = None,
- custom_len: types.Callable[[str], int] = utils.len_color,
+ 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,
- ): # sourcery skip: low-code-quality
+ ):
"""Initializes a progress bar with sane defaults."""
- StdRedirectMixin.__init__(self, **kwargs)
- ResizableMixin.__init__(self, **kwargs)
- ProgressBarBase.__init__(self, **kwargs)
+ super().__init__(**kwargs)
+
+ max_value, poll_interval = self._apply_deprecated_aliases(
+ max_value, poll_interval, kwargs
+ )
+
+ 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 '
@@ -603,41 +777,38 @@ def __init__(
)
poll_interval = kwargs.get('poll')
- if max_value and min_value > types.cast(NumberT, max_value):
- raise ValueError(
- 'Max value needs to be bigger than the min value',
- )
- self.min_value = min_value
- # Legacy issue, `max_value` can be `None` before execution. After
- # that it either has a value or is `UnknownLength`
- self.max_value = max_value # type: ignore
- self.max_error = max_error
+ 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 the widget if it's safe to copy. Most widgets are so we
- # assume this to be true
- self.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)
- self.widgets.append(widget)
-
- self.prefix = prefix
- self.suffix = suffix
- self.widget_kwargs = widget_kwargs or {}
- self.left_justify = left_justify
- self.value = initial_value
- self._iterable = None
- self.custom_len = custom_len # type: ignore
- self.initial_start_time = kwargs.get('start_time')
- self.init()
+ result.append(widget)
+ return result
- # Convert a given timedelta to a floating point number as internal
- # interval. We're not using timedelta's internally for two reasons:
- # 1. Backwards compatibility (most important one)
- # 2. Performance. Even though the amount of time it takes to compare a
- # timedelta with a float versus a float directly is negligible, this
- # comparison is run for _every_ update. With billions of updates
- # (downloading a 1GiB file for example) this adds up.
+ 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,
@@ -657,14 +828,23 @@ def __init__(
float(os.environ.get('PROGRESSBAR_MINIMUM_UPDATE_INTERVAL', 0)),
) # type: ignore
- # A dictionary of names that can be used by Variable and FormatWidget
+ 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 {})
- for widget in self.widgets:
- if (
- isinstance(widget, widgets_module.VariableMixin)
- and widget.name not in self.variables
- ):
- self.variables[widget.name] = None
+ 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
@@ -680,12 +860,29 @@ def init(self):
used (again).
"""
self.previous_value = None
+ # 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()
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) -> float | None:
@@ -730,7 +927,7 @@ def percentage(self) -> float | None:
return percentage
- def data(self) -> types.Dict[str, types.Any]:
+ def data(self) -> dict[str, typing.Any]:
"""
Returns:
@@ -749,7 +946,7 @@ def data(self) -> types.Dict[str, types.Any]:
- `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
+ - `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
@@ -757,12 +954,11 @@ def data(self) -> types.Dict[str, types.Any]:
- `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.
"""
- self._last_update_time = time.time()
- self._last_update_timer = timeit.default_timer()
elapsed = self.last_update_time - self.start_time # type: ignore
- # For Python 2.7 and higher we have _`timedelta.total_seconds`, but we
- # want to support older versions as well
total_seconds_elapsed = utils.deltas_to_seconds(elapsed)
return dict(
# The maximum value (can be None with iterators)
@@ -788,12 +984,14 @@ def data(self) -> types.Dict[str, types.Any]:
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,
@@ -802,6 +1000,8 @@ def data(self) -> types.Dict[str, types.Any]:
)
def default_widgets(self):
+ widgets = _load_widgets()
+
if self.max_value:
return [
widgets.Percentage(**self.widget_kwargs),
@@ -842,7 +1042,98 @@ def __call__(self, iterable, max_value=None):
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
@@ -860,9 +1151,6 @@ def __next__(self):
except StopIteration:
self.finish()
raise
- except GeneratorExit: # pragma: no cover
- self.finish(dirty=True)
- raise
else:
return value
@@ -897,19 +1185,93 @@ def _needs_update(self):
elif self.poll_interval and delta > self.poll_interval:
# Needs to redraw timers and animations
return True
-
- # Update if value increment is not large enough to
- # add more bars to progressbar (according to current
- # terminal width)
- with contextlib.suppress(Exception):
+ 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 # type: ignore
- pvalue_divisor = self.previous_value // divisor # 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
+ )
+
+ 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
):
@@ -917,11 +1279,11 @@ def update(
if self.start_time is None:
self.start()
- if (
- value is not None
- and value is not base.UnknownLength
- and isinstance(value, (int, float))
- ):
+ # `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
@@ -939,14 +1301,20 @@ def update(
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
- # Save the updated values for dynamic messages
- variables_changed = self._update_variables(kwargs)
+ # 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._needs_update() or variables_changed or force:
- self._update_parents(value)
+ if self._gate_skips(value, force, variables_changed):
+ return
+
+ self._draw_and_recalibrate(value, variables_changed, force)
def _update_variables(self, kwargs):
variables_changed = False
@@ -954,18 +1322,40 @@ def _update_variables(self, kwargs):
if key not in self.variables:
raise TypeError(
'update() got an unexpected variable name as argument '
- '{key!r}',
+ 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
- ResizableMixin.update(self, value=value)
- ProgressBarBase.update(self, value=value)
- StdRedirectMixin.update(self, value=value) # type: ignore
+ # 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()
@@ -1006,29 +1396,63 @@ def start(
if self.max_value is None:
self.max_value = self._DEFAULT_MAXVAL
- StdRedirectMixin.start(self, max_value=max_value)
- ResizableMixin.start(self, max_value=max_value)
- ProgressBarBase.start(self, max_value=max_value)
-
- # Constructing the default widgets is only done when we know max_value
+ # 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()
- self._verify_max_value()
+ if (
+ os.environ.get('PROGRESSBAR_DISABLE_FASTPATH')
+ or not self.min_poll_interval
+ ):
+ self._gate_enabled = False
- now = datetime.now()
- self.start_time = self.initial_start_time or now
- self.last_update_time = now
- self._last_update_timer = timeit.default_timer()
- self.update(self.min_value, force=True)
+ try:
+ 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),
)
@@ -1038,6 +1462,8 @@ def _init_suffix(self):
def _init_prefix(self):
if self.prefix:
+ widgets = _load_widgets()
+
self.widgets.insert(
0,
widgets.FormatLabel(self.prefix, new_style=True),
@@ -1080,13 +1506,26 @@ def finish(self, end: str = '\n', dirty: bool = False):
dirty (bool): When True the progressbar kept the current state and
won't be set to 100 percent
"""
- if not dirty:
- self.end_time = datetime.now()
- self.update(self.max_value, force=True)
+ if self._finished:
+ # Finishing twice would corrupt the global stream-wrapping
+ # state, so extra calls are no-ops
+ return
- StdRedirectMixin.finish(self, end=end)
- ResizableMixin.finish(self)
- ProgressBarBase.finish(self)
+ 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)
@property
def currval(self):
@@ -1095,8 +1534,7 @@ def currval(self):
progressbar package.
"""
warnings.warn(
- 'The usage of `currval` is deprecated, please use '
- '`value` instead',
+ 'The usage of `currval` is deprecated, please use `value` instead',
DeprecationWarning,
stacklevel=1,
)
@@ -1110,6 +1548,8 @@ class DataTransferBar(ProgressBar):
"""
def default_widgets(self):
+ widgets = _load_widgets()
+
if self.max_value:
return [
widgets.Percentage(),
diff --git a/progressbar/base.py b/progressbar/base.py
index 24018329..6c80c19d 100644
--- a/progressbar/base.py
+++ b/progressbar/base.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import typing
from typing import IO, TextIO
@@ -9,12 +8,6 @@ class FalseMeta(type):
def __bool__(cls) -> bool: # pragma: no cover
return False
- @classmethod
- def __cmp__(cls, other: typing.Any) -> int: # pragma: no cover
- return -1
-
- __nonzero__ = __bool__
-
class UnknownLength(metaclass=FalseMeta):
pass
@@ -28,9 +21,9 @@ class Undefined(metaclass=FalseMeta):
assert TextIO is not None
__all__ = (
- 'FalseMeta',
- 'UnknownLength',
- 'Undefined',
'IO',
+ 'FalseMeta',
'TextIO',
+ 'Undefined',
+ 'UnknownLength',
)
diff --git a/progressbar/env.py b/progressbar/env.py
index 3871c2ed..2c92b6ed 100644
--- a/progressbar/env.py
+++ b/progressbar/env.py
@@ -45,9 +45,12 @@ def from_env(cls) -> ColorSupport:
"""Get the color support from the environment.
If any of the environment variables contain `24bit` or `truecolor`,
- we will enable true color/24 bit support. If they contain `256`, we
- will enable 256 color/8 bit support. If they contain `xterm`, we will
- enable 16 color support. Otherwise, we will assume no color support.
+ 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.
@@ -62,23 +65,50 @@ def from_env(cls) -> ColorSupport:
'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:
- # Jupyter notebook always supports true color.
- return cls.XTERM_TRUECOLOR
+ return cls._from_jupyter()
elif os.name == 'nt':
- # We can't reliably detect true color support on Windows, so we
- # will assume it is supported if the console is configured to
- # support it.
- from .terminal.os_specific import windows
-
- if (
- windows.get_console_mode()
- & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT
- ):
- return cls.XTERM_TRUECOLOR
- else:
- return cls.WINDOWS # pragma: no cover
+ 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)
@@ -88,10 +118,18 @@ def from_env(cls) -> ColorSupport:
# 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 value == 'xterm':
+ 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
@@ -116,8 +154,11 @@ def is_ansi_terminal(
# is going to return False if the instance has been overridden and
# isatty has not been defined we have no way of knowing so we will not
# use ansi. ansi terminals will typically define one of the 2
- # environment variables.
- with contextlib.suppress(Exception):
+ # 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', '')):
@@ -150,29 +191,24 @@ def is_terminal(
# Allow a environment variable override
is_terminal = env_flag('PROGRESSBAR_IS_TERMINAL', None)
- if is_terminal is None: # pragma: no cover
- # Bare except because a lot can go wrong on different systems. If we do
- # get a TTY we know this is a valid terminal
+ 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 Exception:
+ except (OSError, ValueError, AttributeError):
is_terminal = False
return is_terminal
-# Enable Windows full color mode if possible
-if os.name == 'nt':
- pass
-
- # os_specific.set_console_mode()
-
JUPYTER = bool(
os.environ.get('JUPYTER_COLUMNS')
or os.environ.get('JUPYTER_LINES')
or os.environ.get('JPY_PARENT_PID')
)
-COLOR_SUPPORT = ColorSupport.from_env()
ANSI_TERMS = (
'([xe]|bv)term',
'(sco)?ansi',
@@ -185,5 +221,14 @@ def is_terminal(
'vt(10[02]|220|320)',
)
ANSI_TERM_RE: re.Pattern[str] = re.compile(
- f"^({'|'.join(ANSI_TERMS)})", re.IGNORECASE
+ 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
index 948b20c6..0e0628dd 100644
--- a/progressbar/multi.py
+++ b/progressbar/multi.py
@@ -1,6 +1,8 @@
from __future__ import annotations
+import collections.abc
import enum
+import importlib
import io
import itertools
import operator
@@ -17,7 +19,15 @@
from . import bar, terminal
from .terminal import stream
-SortKeyFunc = typing.Callable[[bar.ProgressBar], typing.Any]
+# 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):
@@ -43,7 +53,19 @@ class SortKey(str, enum.Enum):
PERCENTAGE = 'percentage'
-class MultiBar(typing.Dict[str, bar.ProgressBar]):
+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
@@ -64,6 +86,9 @@ class MultiBar(typing.Dict[str, bar.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]
@@ -74,14 +99,18 @@ class MultiBar(typing.Dict[str, bar.ProgressBar]):
_previous_output: list[str]
_finished_at: dict[bar.ProgressBar, float]
_labeled: set[bar.ProgressBar]
- _print_lock: threading.RLock = threading.RLock()
- _thread: threading.Thread | None = None
- _thread_finished: threading.Event = threading.Event()
- _thread_closed: threading.Event = threading.Event()
+ _print_lock: threading.RLock
+ _thread: threading.Thread | None
+ _thread_finished: threading.Event
+ _thread_closed: threading.Event
def __init__(
self,
- bars: typing.Iterable[tuple[str, bar.ProgressBar]] | None = None,
+ 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,
@@ -95,6 +124,8 @@ def __init__(
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
@@ -112,6 +143,9 @@ def __init__(
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
@@ -125,18 +159,41 @@ def __init__(
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
- super().__init__(bars or {})
+ for key, progress in bar_items:
+ self[key] = progress
- def __setitem__(self, key: str, bar: bar.ProgressBar):
+ 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
+
+ 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
@@ -153,7 +210,7 @@ def __delitem__(self, key: str) -> None:
self._finished_at.pop(bar_, None)
self._labeled.discard(bar_)
- def __getitem__(self, key: str):
+ def __getitem__(self, key: str) -> bar.ProgressBar:
"""Get (and create if needed) a progressbar from the multibar."""
try:
return super().__getitem__(key)
@@ -172,7 +229,7 @@ def _label_bar(self, bar: bar.ProgressBar) -> None:
self._labeled.add(bar)
bar.widgets.insert(0, self.label_format.format(label=bar.label))
- if self.append_label and bar not in self._labeled: # pragma: no branch
+ if self.append_label: # pragma: no branch
self._labeled.add(bar)
bar.widgets.append(self.label_format.format(label=bar.label))
@@ -232,7 +289,7 @@ def _render_bar(
bar_: bar.ProgressBar,
now: float,
expired: float | None,
- ) -> typing.Iterable[str]:
+ ) -> collections.abc.Iterable[str]:
def update(
force: bool = True, write: bool = True
) -> str: # pragma: no cover
@@ -247,7 +304,7 @@ def update(
yield from self._render_finished_bar(bar_, now, expired, update)
elif bar_.started():
- update()
+ yield update()
else:
if self.initial_format is None:
bar_.start()
@@ -261,7 +318,7 @@ def _render_finished_bar(
now: float,
expired: float | None,
update: _Update,
- ) -> typing.Iterable[str]:
+ ) -> collections.abc.Iterable[str]:
if bar_ not in self._finished_at:
self._finished_at[bar_] = now
# Force update to get the finished format
@@ -280,7 +337,7 @@ def _render_finished_bar(
if bar_.finished(): # pragma: no branch
if self.finished_format is None:
- update(force=False)
+ yield update(force=False)
else: # pragma: no cover
yield self.finished_format.format(label=bar_.label)
@@ -330,9 +387,14 @@ def print(
self.flush()
def flush(self) -> None:
- self.fd.write(self._buffer.getvalue())
- self._buffer.truncate(0)
- self.fd.flush()
+ # 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:
"""
@@ -346,7 +408,7 @@ def run(self, join: bool = True) -> None:
if join or self._thread_closed.is_set():
# If the thread is closed, we need to check if the progressbars
# have finished. If they have, we can exit the loop
- for bar_ in self.values(): # pragma: no cover
+ for bar_ in list(self.values()): # pragma: no cover
if not bar_.finished():
break
else:
@@ -357,28 +419,33 @@ def run(self, join: bool = True) -> None:
def start(self) -> None:
assert not self._thread, 'Multibar already started'
- self._thread_closed.set()
- self._thread = threading.Thread(target=self.run, args=(False,))
+ self._thread_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)
- self._thread = None
+ if not self._thread.is_alive():
+ self._thread = None
- def stop(self, timeout: float | None = None):
+ def stop(self, timeout: float | None = None) -> None:
self._thread_finished.set()
self.join(timeout=timeout)
- def get_sorted_bars(self):
- return sorted(
- self.values(),
- key=self.sort_keyfunc,
- reverse=self.sort_reverse,
- )
+ def 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):
+ def __enter__(self) -> MultiBar:
self.start()
return self
@@ -388,4 +455,16 @@ def __exit__(
exc_value: BaseException | None,
traceback: types.TracebackType | None,
) -> bool | None:
- self.join()
+ 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/shortcuts.py b/progressbar/shortcuts.py
index 220c8f23..459c0f71 100644
--- a/progressbar/shortcuts.py
+++ b/progressbar/shortcuts.py
@@ -1,30 +1,62 @@
from __future__ import annotations
+import collections.abc
+import os
import typing
from . import (
bar,
- widgets as widgets_module,
+ fast as fast_module,
)
+if typing.TYPE_CHECKING:
+ from . import widgets as widgets_module
+
T = typing.TypeVar('T')
def progressbar(
- iterator: typing.Iterator[T],
+ iterator: collections.abc.Iterable[T],
min_value: bar.NumberT = 0,
max_value: bar.ValueT = None,
- widgets: typing.Sequence[widgets_module.WidgetBase | str] | None = 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,
-) -> typing.Generator[T, None, None]:
- progressbar_ = bar.ProgressBar(
+) -> 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,
)
- yield from progressbar_(iterator)
+ return iter(progressbar_(iterator))
diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py
index 9cba646c..796c5f6d 100644
--- a/progressbar/terminal/base.py
+++ b/progressbar/terminal/base.py
@@ -1,10 +1,9 @@
from __future__ import annotations
import abc
-import collections
+import collections.abc
import colorsys
import enum
-import threading
import typing
from collections import defaultdict
@@ -12,13 +11,17 @@
# `types` module
from typing import ClassVar
-from python_utils import converters, types
+from python_utils import converters
from .. import (
base as pbase,
env,
)
-from .os_specific import getch
+
+# 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'
@@ -145,43 +148,6 @@ def clear_line(n: int):
return UP(n) + CLEAR_LINE_ALL() + DOWN(n)
-# Report Cursor Position (CPR), response = [row;column] as row;columnR
-class _CPR(str): # pragma: no cover # pyright: ignore[reportUnusedClass]
- _response_lock = threading.Lock()
-
- def __call__(self, stream: typing.IO[str]) -> tuple[int, int]:
- res: str = ''
-
- with self._response_lock:
- stream.write(str(self))
- stream.flush()
-
- while not res.endswith('R'):
- char = getch()
-
- if char:
- res += char
-
- res_list = res[2:-1].split(';')
-
- res_list = tuple(
- int(item) if item.isdigit() else item for item in res_list
- )
-
- if len(res_list) == 1:
- return types.cast(types.Tuple[int, int], res_list[0])
-
- return types.cast(types.Tuple[int, int], tuple(res_list))
-
- def row(self, stream: typing.IO[str]) -> int:
- row, _ = self(stream)
- return row
-
- def column(self, stream: typing.IO[str]) -> int:
- _, column = self(stream)
- return column
-
-
class WindowsColors(enum.Enum):
BLACK = 0, 0, 0
BLUE = 0, 0, 128
@@ -201,7 +167,7 @@ class WindowsColors(enum.Enum):
INTENSE_WHITE = 255, 255, 255
@staticmethod
- def from_rgb(rgb: types.Tuple[int, int, int]) -> WindowsColors:
+ def from_rgb(rgb: tuple[int, int, int]) -> WindowsColors:
"""
Find the closest WindowsColors to the given RGB color.
@@ -225,7 +191,9 @@ def color_distance(
rgb1: tuple[int, int, int],
rgb2: tuple[int, int, int],
):
- return sum((c1 - c2) ** 2 for c1, c2 in zip(rgb1, rgb2))
+ return sum(
+ (c1 - c2) ** 2 for c1, c2 in zip(rgb1, rgb2, strict=False)
+ )
return min(
WindowsColors,
@@ -280,10 +248,12 @@ def hex(self) -> str:
@property
def to_ansi_16(self) -> int:
- # Using int instead of round because it maps slightly better
- red = int(self.red / 255)
- green = int(self.green / 255)
- blue = int(self.blue / 255)
+ # 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
@@ -341,8 +311,8 @@ def from_rgb(cls, rgb: RGB) -> HSL:
def interpolate(self, end: HSL, step: float) -> HSL:
return HSL(
self.hue + (end.hue - self.hue) * step,
- self.lightness + (end.lightness - self.lightness) * step,
self.saturation + (end.saturation - self.saturation) * step,
+ self.lightness + (end.lightness - self.lightness) * step,
)
@@ -399,20 +369,26 @@ def underline(self) -> DummyColor | SGRColor:
return SGRColor(self, 58, 59)
@property
- def ansi(self) -> types.Optional[str]:
+ 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}'
- if self.xterm: # pragma: no branch
+ # 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
- elif env.COLOR_SUPPORT is env.ColorSupport.XTERM: # pragma: no branch
- color = self.rgb.to_ansi_16
else: # pragma: no branch
return None
@@ -440,30 +416,20 @@ def __hash__(self) -> int:
class Colors:
- by_name: ClassVar[defaultdict[str, types.List[Color]]] = (
- collections.defaultdict(list)
- )
- by_lowername: ClassVar[defaultdict[str, types.List[Color]]] = (
- collections.defaultdict(list)
- )
- by_hex: ClassVar[defaultdict[str, types.List[Color]]] = (
- collections.defaultdict(list)
- )
- by_rgb: ClassVar[defaultdict[RGB, types.List[Color]]] = (
- collections.defaultdict(list)
- )
- by_hls: ClassVar[defaultdict[HSL, types.List[Color]]] = (
- collections.defaultdict(list)
- )
+ by_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: types.Optional[HSL] = None,
- name: types.Optional[str] = None,
- xterm: types.Optional[int] = None,
+ hls: HSL | None = None,
+ name: str | None = None,
+ xterm: int | None = None,
) -> Color:
if hls is None:
hls = HSL.from_rgb(rgb)
@@ -489,14 +455,14 @@ def interpolate(cls, color_a: Color, color_b: Color, step: float) -> Color:
class ColorGradient:
- interpolate: typing.Callable[[Color, Color, float], Color] | None
+ interpolate: collections.abc.Callable[[Color, Color, float], Color] | None
colors: tuple[Color, ...]
def __init__(
self,
*colors: Color,
interpolate: (
- typing.Callable[[Color, Color, float], Color] | None
+ collections.abc.Callable[[Color, Color, float], Color] | None
) = Colors.interpolate,
) -> None:
assert colors
@@ -547,7 +513,7 @@ def get_color(self, value: float) -> Color:
return color
-OptionalColor = types.Union[Color, ColorGradient, None]
+OptionalColor = Color | ColorGradient | None
def get_color(value: float, color: OptionalColor) -> Color | None:
@@ -564,7 +530,7 @@ def apply_colors(
bg: OptionalColor = None,
fg_none: Color | None = None,
bg_none: Color | None = None,
- **kwargs: types.Any,
+ **kwargs: typing.Any,
) -> str:
"""Apply colors/gradients to a string depending on the given percentage.
@@ -601,7 +567,7 @@ class SGR(CSI):
_start_code: int
_end_code: int
_code = 'm'
- __slots__ = '_start_code', '_end_code'
+ __slots__ = '_end_code', '_start_code'
def __init__(self, start_code: int, end_code: int) -> None:
self._start_code = start_code
@@ -624,12 +590,24 @@ def __call__( # pyright: ignore[reportIncompatibleMethodOverride]
class SGRColor(SGR):
- __slots__ = '_color', '_start_code', '_end_code'
+ __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)
diff --git a/progressbar/terminal/colors.py b/progressbar/terminal/colors.py
index 37e5ea90..1da55c8a 100644
--- a/progressbar/terminal/colors.py
+++ b/progressbar/terminal/colors.py
@@ -22,46 +22,46 @@
aqua = Colors.register(RGB(0, 255, 255), HSL(180, 100, 50), 'Aqua', 14)
white = Colors.register(RGB(255, 255, 255), HSL(0, 0, 100), 'White', 15)
grey0 = Colors.register(RGB(0, 0, 0), HSL(0, 0, 0), 'Grey0', 16)
-navy_blue = Colors.register(RGB(0, 0, 95), HSL(240, 100, 18), 'NavyBlue', 17)
+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, 18), 'DarkGreen', 22)
+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, 18),
+ HSL(180, 100, 19),
'DeepSkyBlue4',
23,
)
deep_sky_blue4 = Colors.register(
RGB(0, 95, 135),
- HSL(97, 100, 26),
+ HSL(198, 100, 26),
'DeepSkyBlue4',
24,
)
deep_sky_blue4 = Colors.register(
RGB(0, 95, 175),
- HSL(7, 100, 34),
+ HSL(207, 100, 34),
'DeepSkyBlue4',
25,
)
dodger_blue3 = Colors.register(
RGB(0, 95, 215),
- HSL(13, 100, 42),
+ HSL(213, 100, 42),
'DodgerBlue3',
26,
)
dodger_blue2 = Colors.register(
RGB(0, 95, 255),
- HSL(17, 100, 50),
+ 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(62, 100, 26),
+ HSL(162, 100, 26),
'SpringGreen4',
29,
)
@@ -73,30 +73,35 @@
)
deep_sky_blue3 = Colors.register(
RGB(0, 135, 175),
- HSL(93, 100, 34),
+ HSL(194, 100, 34),
'DeepSkyBlue3',
31,
)
deep_sky_blue3 = Colors.register(
RGB(0, 135, 215),
- HSL(2, 100, 42),
+ HSL(202, 100, 42),
'DeepSkyBlue3',
32,
)
dodger_blue1 = Colors.register(
RGB(0, 135, 255),
- HSL(8, 100, 50),
+ 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(52, 100, 34),
+ HSL(153, 100, 34),
'SpringGreen3',
35,
)
-dark_cyan = Colors.register(RGB(0, 175, 135), HSL(66, 100, 34), 'DarkCyan', 36)
+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),
@@ -105,30 +110,30 @@
)
deep_sky_blue2 = Colors.register(
RGB(0, 175, 215),
- HSL(91, 100, 42),
+ HSL(191, 100, 42),
'DeepSkyBlue2',
38,
)
deep_sky_blue1 = Colors.register(
RGB(0, 175, 255),
- HSL(98, 100, 50),
+ 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(46, 100, 42),
+ HSL(147, 100, 42),
'SpringGreen3',
41,
)
spring_green2 = Colors.register(
RGB(0, 215, 135),
- HSL(57, 100, 42),
+ HSL(158, 100, 42),
'SpringGreen2',
42,
)
-cyan3 = Colors.register(RGB(0, 215, 175), HSL(68, 100, 42), 'Cyan3', 43)
+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),
@@ -137,48 +142,48 @@
)
turquoise2 = Colors.register(
RGB(0, 215, 255),
- HSL(89, 100, 50),
+ 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(42, 100, 50),
+ HSL(142, 100, 50),
'SpringGreen2',
47,
)
spring_green1 = Colors.register(
RGB(0, 255, 135),
- HSL(51, 100, 50),
+ HSL(152, 100, 50),
'SpringGreen1',
48,
)
medium_spring_green = Colors.register(
RGB(0, 255, 175),
- HSL(61, 100, 50),
+ HSL(161, 100, 50),
'MediumSpringGreen',
49,
)
-cyan2 = Colors.register(RGB(0, 255, 215), HSL(70, 100, 50), 'Cyan2', 50)
+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, 18), 'DarkRed', 52)
+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, 18),
+ HSL(300, 100, 19),
'DeepPink4',
53,
)
-purple4 = Colors.register(RGB(95, 0, 135), HSL(82, 100, 26), 'Purple4', 54)
-purple4 = Colors.register(RGB(95, 0, 175), HSL(72, 100, 34), 'Purple4', 55)
-purple3 = Colors.register(RGB(95, 0, 215), HSL(66, 100, 42), 'Purple3', 56)
+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(62, 100, 50),
+ HSL(262, 100, 50),
'BlueViolet',
57,
)
-orange4 = Colors.register(RGB(95, 95, 0), HSL(60, 100, 18), 'Orange4', 58)
+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),
@@ -188,25 +193,25 @@
)
slate_blue3 = Colors.register(
RGB(95, 95, 175),
- HSL(240, 33, 52),
+ HSL(240, 33, 53),
'SlateBlue3',
61,
)
slate_blue3 = Colors.register(
RGB(95, 95, 215),
- HSL(240, 60, 60),
+ HSL(240, 60, 61),
'SlateBlue3',
62,
)
royal_blue1 = Colors.register(
RGB(95, 95, 255),
- HSL(240, 100, 68),
+ HSL(240, 100, 69),
'RoyalBlue1',
63,
)
chartreuse4 = Colors.register(
RGB(95, 135, 0),
- HSL(7, 100, 26),
+ HSL(78, 100, 26),
'Chartreuse4',
64,
)
@@ -224,134 +229,134 @@
)
steel_blue = Colors.register(
RGB(95, 135, 175),
- HSL(210, 33, 52),
+ HSL(210, 33, 53),
'SteelBlue',
67,
)
steel_blue3 = Colors.register(
RGB(95, 135, 215),
- HSL(220, 60, 60),
+ HSL(220, 60, 61),
'SteelBlue3',
68,
)
cornflower_blue = Colors.register(
RGB(95, 135, 255),
- HSL(225, 100, 68),
+ HSL(225, 100, 69),
'CornflowerBlue',
69,
)
chartreuse3 = Colors.register(
RGB(95, 175, 0),
- HSL(7, 100, 34),
+ HSL(87, 100, 34),
'Chartreuse3',
70,
)
dark_sea_green4 = Colors.register(
RGB(95, 175, 95),
- HSL(120, 33, 52),
+ HSL(120, 33, 53),
'DarkSeaGreen4',
71,
)
cadet_blue = Colors.register(
RGB(95, 175, 135),
- HSL(150, 33, 52),
+ HSL(150, 33, 53),
'CadetBlue',
72,
)
cadet_blue = Colors.register(
RGB(95, 175, 175),
- HSL(180, 33, 52),
+ HSL(180, 33, 53),
'CadetBlue',
73,
)
sky_blue3 = Colors.register(
RGB(95, 175, 215),
- HSL(200, 60, 60),
+ HSL(200, 60, 61),
'SkyBlue3',
74,
)
steel_blue1 = Colors.register(
RGB(95, 175, 255),
- HSL(210, 100, 68),
+ HSL(210, 100, 69),
'SteelBlue1',
75,
)
chartreuse3 = Colors.register(
RGB(95, 215, 0),
- HSL(3, 100, 42),
+ HSL(93, 100, 42),
'Chartreuse3',
76,
)
pale_green3 = Colors.register(
RGB(95, 215, 95),
- HSL(120, 60, 60),
+ HSL(120, 60, 61),
'PaleGreen3',
77,
)
sea_green3 = Colors.register(
RGB(95, 215, 135),
- HSL(140, 60, 60),
+ HSL(140, 60, 61),
'SeaGreen3',
78,
)
aquamarine3 = Colors.register(
RGB(95, 215, 175),
- HSL(160, 60, 60),
+ HSL(160, 60, 61),
'Aquamarine3',
79,
)
medium_turquoise = Colors.register(
RGB(95, 215, 215),
- HSL(180, 60, 60),
+ HSL(180, 60, 61),
'MediumTurquoise',
80,
)
steel_blue1 = Colors.register(
RGB(95, 215, 255),
- HSL(195, 100, 68),
+ HSL(195, 100, 69),
'SteelBlue1',
81,
)
chartreuse2 = Colors.register(
RGB(95, 255, 0),
- HSL(7, 100, 50),
+ HSL(98, 100, 50),
'Chartreuse2',
82,
)
sea_green2 = Colors.register(
RGB(95, 255, 95),
- HSL(120, 100, 68),
+ HSL(120, 100, 69),
'SeaGreen2',
83,
)
sea_green1 = Colors.register(
RGB(95, 255, 135),
- HSL(135, 100, 68),
+ HSL(135, 100, 69),
'SeaGreen1',
84,
)
sea_green1 = Colors.register(
RGB(95, 255, 175),
- HSL(150, 100, 68),
+ HSL(150, 100, 69),
'SeaGreen1',
85,
)
aquamarine1 = Colors.register(
RGB(95, 255, 215),
- HSL(165, 100, 68),
+ HSL(165, 100, 69),
'Aquamarine1',
86,
)
dark_slate_gray2 = Colors.register(
RGB(95, 255, 255),
- HSL(180, 100, 68),
+ 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(17, 100, 26),
+ HSL(318, 100, 26),
'DeepPink4',
89,
)
@@ -363,18 +368,18 @@
)
dark_magenta = Colors.register(
RGB(135, 0, 175),
- HSL(86, 100, 34),
+ HSL(286, 100, 34),
'DarkMagenta',
91,
)
dark_violet = Colors.register(
RGB(135, 0, 215),
- HSL(77, 100, 42),
+ HSL(278, 100, 42),
'DarkViolet',
92,
)
-purple = Colors.register(RGB(135, 0, 255), HSL(71, 100, 50), 'Purple', 93)
-orange4 = Colors.register(RGB(135, 95, 0), HSL(2, 100, 26), 'Orange4', 94)
+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),
@@ -384,34 +389,34 @@
plum4 = Colors.register(RGB(135, 95, 135), HSL(300, 17, 45), 'Plum4', 96)
medium_purple3 = Colors.register(
RGB(135, 95, 175),
- HSL(270, 33, 52),
+ HSL(270, 33, 53),
'MediumPurple3',
97,
)
medium_purple3 = Colors.register(
RGB(135, 95, 215),
- HSL(260, 60, 60),
+ HSL(260, 60, 61),
'MediumPurple3',
98,
)
slate_blue1 = Colors.register(
RGB(135, 95, 255),
- HSL(255, 100, 68),
+ 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, 52), 'Grey53', 102)
+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, 60),
+ HSL(240, 20, 61),
'LightSlateGrey',
103,
)
medium_purple = Colors.register(
RGB(135, 135, 215),
- HSL(240, 50, 68),
+ HSL(240, 50, 69),
'MediumPurple',
104,
)
@@ -421,28 +426,28 @@
'LightSlateBlue',
105,
)
-yellow4 = Colors.register(RGB(135, 175, 0), HSL(3, 100, 34), 'Yellow4', 106)
+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, 52),
+ HSL(90, 33, 53),
'DarkOliveGreen3',
107,
)
dark_sea_green = Colors.register(
RGB(135, 175, 135),
- HSL(120, 20, 60),
+ HSL(120, 20, 61),
'DarkSeaGreen',
108,
)
light_sky_blue3 = Colors.register(
RGB(135, 175, 175),
- HSL(180, 20, 60),
+ HSL(180, 20, 61),
'LightSkyBlue3',
109,
)
light_sky_blue3 = Colors.register(
RGB(135, 175, 215),
- HSL(210, 50, 68),
+ HSL(210, 50, 69),
'LightSkyBlue3',
110,
)
@@ -454,31 +459,31 @@
)
chartreuse2 = Colors.register(
RGB(135, 215, 0),
- HSL(2, 100, 42),
+ HSL(82, 100, 42),
'Chartreuse2',
112,
)
dark_olive_green3 = Colors.register(
RGB(135, 215, 95),
- HSL(100, 60, 60),
+ HSL(100, 60, 61),
'DarkOliveGreen3',
113,
)
pale_green3 = Colors.register(
RGB(135, 215, 135),
- HSL(120, 50, 68),
+ HSL(120, 50, 69),
'PaleGreen3',
114,
)
dark_sea_green3 = Colors.register(
RGB(135, 215, 175),
- HSL(150, 50, 68),
+ HSL(150, 50, 69),
'DarkSeaGreen3',
115,
)
dark_slate_gray3 = Colors.register(
RGB(135, 215, 215),
- HSL(180, 50, 68),
+ HSL(180, 50, 69),
'DarkSlateGray3',
116,
)
@@ -490,13 +495,13 @@
)
chartreuse1 = Colors.register(
RGB(135, 255, 0),
- HSL(8, 100, 50),
+ HSL(88, 100, 50),
'Chartreuse1',
118,
)
light_green = Colors.register(
RGB(135, 255, 95),
- HSL(105, 100, 68),
+ HSL(105, 100, 69),
'LightGreen',
119,
)
@@ -527,13 +532,13 @@
red3 = Colors.register(RGB(175, 0, 0), HSL(0, 100, 34), 'Red3', 124)
deep_pink4 = Colors.register(
RGB(175, 0, 95),
- HSL(27, 100, 34),
+ HSL(327, 100, 34),
'DeepPink4',
125,
)
medium_violet_red = Colors.register(
RGB(175, 0, 135),
- HSL(13, 100, 34),
+ HSL(314, 100, 34),
'MediumVioletRed',
126,
)
@@ -545,69 +550,69 @@
)
dark_violet = Colors.register(
RGB(175, 0, 215),
- HSL(88, 100, 42),
+ HSL(289, 100, 42),
'DarkViolet',
128,
)
-purple = Colors.register(RGB(175, 0, 255), HSL(81, 100, 50), 'Purple', 129)
+purple = Colors.register(RGB(175, 0, 255), HSL(281, 100, 50), 'Purple', 129)
dark_orange3 = Colors.register(
RGB(175, 95, 0),
- HSL(2, 100, 34),
+ HSL(33, 100, 34),
'DarkOrange3',
130,
)
indian_red = Colors.register(
RGB(175, 95, 95),
- HSL(0, 33, 52),
+ HSL(0, 33, 53),
'IndianRed',
131,
)
hot_pink3 = Colors.register(
RGB(175, 95, 135),
- HSL(330, 33, 52),
+ HSL(330, 33, 53),
'HotPink3',
132,
)
medium_orchid3 = Colors.register(
RGB(175, 95, 175),
- HSL(300, 33, 52),
+ HSL(300, 33, 53),
'MediumOrchid3',
133,
)
medium_orchid = Colors.register(
RGB(175, 95, 215),
- HSL(280, 60, 60),
+ HSL(280, 60, 61),
'MediumOrchid',
134,
)
medium_purple2 = Colors.register(
RGB(175, 95, 255),
- HSL(270, 100, 68),
+ HSL(270, 100, 69),
'MediumPurple2',
135,
)
dark_goldenrod = Colors.register(
RGB(175, 135, 0),
- HSL(6, 100, 34),
+ HSL(46, 100, 34),
'DarkGoldenrod',
136,
)
light_salmon3 = Colors.register(
RGB(175, 135, 95),
- HSL(30, 33, 52),
+ HSL(30, 33, 53),
'LightSalmon3',
137,
)
rosy_brown = Colors.register(
RGB(175, 135, 135),
- HSL(0, 20, 60),
+ HSL(0, 20, 61),
'RosyBrown',
138,
)
-grey63 = Colors.register(RGB(175, 135, 175), HSL(300, 20, 60), 'Grey63', 139)
+grey63 = Colors.register(RGB(175, 135, 175), HSL(300, 20, 61), 'Grey63', 139)
medium_purple2 = Colors.register(
RGB(175, 135, 215),
- HSL(270, 50, 68),
+ HSL(270, 50, 69),
'MediumPurple2',
140,
)
@@ -620,17 +625,17 @@
gold3 = Colors.register(RGB(175, 175, 0), HSL(60, 100, 34), 'Gold3', 142)
dark_khaki = Colors.register(
RGB(175, 175, 95),
- HSL(60, 33, 52),
+ HSL(60, 33, 53),
'DarkKhaki',
143,
)
navajo_white3 = Colors.register(
RGB(175, 175, 135),
- HSL(60, 20, 60),
+ HSL(60, 20, 61),
'NavajoWhite3',
144,
)
-grey69 = Colors.register(RGB(175, 175, 175), HSL(0, 0, 68), 'Grey69', 145)
+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),
@@ -643,16 +648,16 @@
'LightSteelBlue',
147,
)
-yellow3 = Colors.register(RGB(175, 215, 0), HSL(1, 100, 42), 'Yellow3', 148)
+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, 60),
+ HSL(80, 60, 61),
'DarkOliveGreen3',
149,
)
dark_sea_green3 = Colors.register(
RGB(175, 215, 135),
- HSL(90, 50, 68),
+ HSL(90, 50, 69),
'DarkSeaGreen3',
150,
)
@@ -676,13 +681,13 @@
)
green_yellow = Colors.register(
RGB(175, 255, 0),
- HSL(8, 100, 50),
+ HSL(79, 100, 50),
'GreenYellow',
154,
)
dark_olive_green2 = Colors.register(
RGB(175, 255, 95),
- HSL(90, 100, 68),
+ HSL(90, 100, 69),
'DarkOliveGreen2',
155,
)
@@ -713,79 +718,89 @@
red3 = Colors.register(RGB(215, 0, 0), HSL(0, 100, 42), 'Red3', 160)
deep_pink3 = Colors.register(
RGB(215, 0, 95),
- HSL(33, 100, 42),
+ HSL(333, 100, 42),
'DeepPink3',
161,
)
deep_pink3 = Colors.register(
RGB(215, 0, 135),
- HSL(22, 100, 42),
+ HSL(322, 100, 42),
'DeepPink3',
162,
)
-magenta3 = Colors.register(RGB(215, 0, 175), HSL(11, 100, 42), 'Magenta3', 163)
+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(90, 100, 50), 'Magenta2', 165)
+magenta2 = Colors.register(
+ RGB(215, 0, 255),
+ HSL(291, 100, 50),
+ 'Magenta2',
+ 165,
+)
dark_orange3 = Colors.register(
RGB(215, 95, 0),
- HSL(6, 100, 42),
+ HSL(27, 100, 42),
'DarkOrange3',
166,
)
indian_red = Colors.register(
RGB(215, 95, 95),
- HSL(0, 60, 60),
+ HSL(0, 60, 61),
'IndianRed',
167,
)
hot_pink3 = Colors.register(
RGB(215, 95, 135),
- HSL(340, 60, 60),
+ HSL(340, 60, 61),
'HotPink3',
168,
)
hot_pink2 = Colors.register(
RGB(215, 95, 175),
- HSL(320, 60, 60),
+ HSL(320, 60, 61),
'HotPink2',
169,
)
-orchid = Colors.register(RGB(215, 95, 215), HSL(300, 60, 60), 'Orchid', 170)
+orchid = Colors.register(RGB(215, 95, 215), HSL(300, 60, 61), 'Orchid', 170)
medium_orchid1 = Colors.register(
RGB(215, 95, 255),
- HSL(285, 100, 68),
+ HSL(285, 100, 69),
'MediumOrchid1',
171,
)
-orange3 = Colors.register(RGB(215, 135, 0), HSL(7, 100, 42), 'Orange3', 172)
+orange3 = Colors.register(RGB(215, 135, 0), HSL(38, 100, 42), 'Orange3', 172)
light_salmon3 = Colors.register(
RGB(215, 135, 95),
- HSL(20, 60, 60),
+ HSL(20, 60, 61),
'LightSalmon3',
173,
)
light_pink3 = Colors.register(
RGB(215, 135, 135),
- HSL(0, 50, 68),
+ HSL(0, 50, 69),
'LightPink3',
174,
)
-pink3 = Colors.register(RGB(215, 135, 175), HSL(330, 50, 68), 'Pink3', 175)
-plum3 = Colors.register(RGB(215, 135, 215), HSL(300, 50, 68), 'Plum3', 176)
+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(8, 100, 42), 'Gold3', 178)
+gold3 = Colors.register(RGB(215, 175, 0), HSL(49, 100, 42), 'Gold3', 178)
light_goldenrod3 = Colors.register(
RGB(215, 175, 95),
- HSL(40, 60, 60),
+ HSL(40, 60, 61),
'LightGoldenrod3',
179,
)
-tan = Colors.register(RGB(215, 175, 135), HSL(30, 50, 68), 'Tan', 180)
+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),
@@ -800,10 +815,10 @@
)
plum2 = Colors.register(RGB(215, 175, 255), HSL(270, 100, 84), 'Plum2', 183)
yellow3 = Colors.register(RGB(215, 215, 0), HSL(60, 100, 42), 'Yellow3', 184)
-khaki3 = Colors.register(RGB(215, 215, 95), HSL(60, 60, 60), 'Khaki3', 185)
+khaki3 = Colors.register(RGB(215, 215, 95), HSL(60, 60, 61), 'Khaki3', 185)
light_goldenrod2 = Colors.register(
RGB(215, 215, 135),
- HSL(60, 50, 68),
+ HSL(60, 50, 69),
'LightGoldenrod2',
186,
)
@@ -820,10 +835,10 @@
'LightSteelBlue1',
189,
)
-yellow2 = Colors.register(RGB(215, 255, 0), HSL(9, 100, 50), 'Yellow2', 190)
+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, 68),
+ HSL(75, 100, 69),
'DarkOliveGreen1',
191,
)
@@ -854,23 +869,28 @@
red1 = Colors.register(RGB(255, 0, 0), HSL(0, 100, 50), 'Red1', 196)
deep_pink2 = Colors.register(
RGB(255, 0, 95),
- HSL(37, 100, 50),
+ HSL(338, 100, 50),
'DeepPink2',
197,
)
deep_pink1 = Colors.register(
RGB(255, 0, 135),
- HSL(28, 100, 50),
+ HSL(328, 100, 50),
'DeepPink1',
198,
)
deep_pink1 = Colors.register(
RGB(255, 0, 175),
- HSL(18, 100, 50),
+ HSL(319, 100, 50),
'DeepPink1',
199,
)
-magenta2 = Colors.register(RGB(255, 0, 215), HSL(9, 100, 50), 'Magenta2', 200)
+magenta2 = Colors.register(
+ RGB(255, 0, 215),
+ HSL(309, 100, 50),
+ 'Magenta2',
+ 200,
+)
magenta1 = Colors.register(
RGB(255, 0, 255),
HSL(300, 100, 50),
@@ -879,47 +899,47 @@
)
orange_red1 = Colors.register(
RGB(255, 95, 0),
- HSL(2, 100, 50),
+ HSL(22, 100, 50),
'OrangeRed1',
202,
)
indian_red1 = Colors.register(
RGB(255, 95, 95),
- HSL(0, 100, 68),
+ HSL(0, 100, 69),
'IndianRed1',
203,
)
indian_red1 = Colors.register(
RGB(255, 95, 135),
- HSL(345, 100, 68),
+ HSL(345, 100, 69),
'IndianRed1',
204,
)
hot_pink = Colors.register(
RGB(255, 95, 175),
- HSL(330, 100, 68),
+ HSL(330, 100, 69),
'HotPink',
205,
)
hot_pink = Colors.register(
RGB(255, 95, 215),
- HSL(315, 100, 68),
+ HSL(315, 100, 69),
'HotPink',
206,
)
medium_orchid1 = Colors.register(
RGB(255, 95, 255),
- HSL(300, 100, 68),
+ HSL(300, 100, 69),
'MediumOrchid1',
207,
)
dark_orange = Colors.register(
RGB(255, 135, 0),
- HSL(1, 100, 50),
+ HSL(32, 100, 50),
'DarkOrange',
208,
)
-salmon1 = Colors.register(RGB(255, 135, 95), HSL(15, 100, 68), 'Salmon1', 209)
+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),
@@ -944,10 +964,10 @@
'Orchid1',
213,
)
-orange1 = Colors.register(RGB(255, 175, 0), HSL(1, 100, 50), 'Orange1', 214)
+orange1 = Colors.register(RGB(255, 175, 0), HSL(41, 100, 50), 'Orange1', 214)
sandy_brown = Colors.register(
RGB(255, 175, 95),
- HSL(30, 100, 68),
+ HSL(30, 100, 69),
'SandyBrown',
215,
)
@@ -965,10 +985,10 @@
)
pink1 = Colors.register(RGB(255, 175, 215), HSL(330, 100, 84), 'Pink1', 218)
plum1 = Colors.register(RGB(255, 175, 255), HSL(300, 100, 84), 'Plum1', 219)
-gold1 = Colors.register(RGB(255, 215, 0), HSL(0, 100, 50), 'Gold1', 220)
+gold1 = Colors.register(RGB(255, 215, 0), HSL(51, 100, 50), 'Gold1', 220)
light_goldenrod2 = Colors.register(
RGB(255, 215, 95),
- HSL(45, 100, 68),
+ HSL(45, 100, 69),
'LightGoldenrod2',
221,
)
@@ -999,7 +1019,7 @@
yellow1 = Colors.register(RGB(255, 255, 0), HSL(60, 100, 50), 'Yellow1', 226)
light_goldenrod1 = Colors.register(
RGB(255, 255, 95),
- HSL(60, 100, 68),
+ HSL(60, 100, 69),
'LightGoldenrod1',
227,
)
@@ -1014,25 +1034,25 @@
grey100 = Colors.register(RGB(255, 255, 255), HSL(0, 0, 100), 'Grey100', 231)
grey3 = Colors.register(RGB(8, 8, 8), HSL(0, 0, 3), 'Grey3', 232)
grey7 = Colors.register(RGB(18, 18, 18), HSL(0, 0, 7), 'Grey7', 233)
-grey11 = Colors.register(RGB(28, 28, 28), HSL(0, 0, 10), 'Grey11', 234)
-grey15 = Colors.register(RGB(38, 38, 38), HSL(0, 0, 14), 'Grey15', 235)
-grey19 = Colors.register(RGB(48, 48, 48), HSL(0, 0, 18), 'Grey19', 236)
-grey23 = Colors.register(RGB(58, 58, 58), HSL(0, 0, 22), 'Grey23', 237)
-grey27 = Colors.register(RGB(68, 68, 68), HSL(0, 0, 26), 'Grey27', 238)
-grey30 = Colors.register(RGB(78, 78, 78), HSL(0, 0, 30), 'Grey30', 239)
-grey35 = Colors.register(RGB(88, 88, 88), HSL(0, 0, 34), 'Grey35', 240)
-grey39 = Colors.register(RGB(98, 98, 98), HSL(0, 0, 37), 'Grey39', 241)
-grey42 = Colors.register(RGB(108, 108, 108), HSL(0, 0, 40), 'Grey42', 242)
+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, 61), 'Grey62', 247)
-grey66 = Colors.register(RGB(168, 168, 168), HSL(0, 0, 65), 'Grey66', 248)
-grey70 = Colors.register(RGB(178, 178, 178), HSL(0, 0, 69), 'Grey70', 249)
-grey74 = Colors.register(RGB(188, 188, 188), HSL(0, 0, 73), 'Grey74', 250)
-grey78 = Colors.register(RGB(198, 198, 198), HSL(0, 0, 77), 'Grey78', 251)
-grey82 = Colors.register(RGB(208, 208, 208), HSL(0, 0, 81), 'Grey82', 252)
+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)
diff --git a/progressbar/terminal/os_specific/posix.py b/progressbar/terminal/os_specific/posix.py
index 34819983..ee873dcb 100644
--- a/progressbar/terminal/os_specific/posix.py
+++ b/progressbar/terminal/os_specific/posix.py
@@ -4,6 +4,10 @@
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:
diff --git a/progressbar/terminal/os_specific/windows.py b/progressbar/terminal/os_specific/windows.py
index 8d1f3f4b..9afd031c 100644
--- a/progressbar/terminal/os_specific/windows.py
+++ b/progressbar/terminal/os_specific/windows.py
@@ -25,6 +25,18 @@
_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):
@@ -48,25 +60,33 @@ 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
-_ReadConsoleInput = _kernel32.ReadConsoleInputA
-_ReadConsoleInput.restype = _BOOL
+_SetConsoleTextAttribute = _kernel32.SetConsoleTextAttribute
+_SetConsoleTextAttribute.argtypes = (_HANDLE, _WORD)
+_SetConsoleTextAttribute.restype = _BOOL
_h_console_input = _GetStdHandle(_STD_INPUT_HANDLE)
_input_mode = _DWORD()
-_GetConsoleMode(_HANDLE(_h_console_input), ctypes.byref(_input_mode))
+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()
-_GetConsoleMode(_HANDLE(_h_console_output), ctypes.byref(_output_mode))
+if _valid_handle(_h_console_output):
+ _GetConsoleMode(_HANDLE(_h_console_output), ctypes.byref(_output_mode))
class _COORD(ctypes.Structure):
@@ -121,17 +141,34 @@ class _Event(ctypes.Union):
_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:
- _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(_input_mode.value))
- _SetConsoleMode(_HANDLE(_h_console_output), _DWORD(_output_mode.value))
+ 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:
- mode = (
- _input_mode.value
- | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_INPUT
- )
- _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(mode))
+ 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
@@ -146,7 +183,8 @@ def get_console_mode() -> int:
def set_text_color(color) -> None:
- _kernel32.SetConsoleTextAttribute(_h_console_output, color)
+ if _valid_handle(_h_console_output):
+ _SetConsoleTextAttribute(_HANDLE(_h_console_output), _WORD(color))
def print_color(text, color) -> None:
@@ -156,19 +194,36 @@ def print_color(text, color) -> None:
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()
- _ReadConsoleInput(
+ if not _ReadConsoleInput(
_HANDLE(_h_console_input),
lp_buffer,
n_length,
ctypes.byref(lp_number_of_events_read),
- )
-
- char = lp_buffer[1].Event.KeyEvent.uChar.AsciiChar.decode('ascii')
- if char == '\x00':
+ ):
return None
- return char
+ # 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
index eb8de2a3..707f0485 100644
--- a/progressbar/terminal/stream.py
+++ b/progressbar/terminal/stream.py
@@ -2,8 +2,8 @@
import sys
import typing
+from collections.abc import Generator, Iterable, Iterator
from types import TracebackType
-from typing import Iterable, Iterator
from progressbar import base
@@ -19,7 +19,7 @@ def fileno(self) -> int:
return self.stream.fileno()
def flush(self) -> None:
- pass
+ self.stream.flush()
def isatty(self) -> bool:
return self.stream.isatty()
@@ -83,6 +83,7 @@ def __init__(
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)
@@ -94,7 +95,9 @@ def write(self, data: str) -> int:
self.stream.write(self.DOWN * self.lines)
self.flush()
- return len(data)
+ # Return the length of the original data; callers use this to
+ # detect short writes
+ return written
class LastLineStream(TextIOOutputWrapper):
@@ -130,7 +133,7 @@ def truncate(self, __size: int | None = None) -> int:
return len(self.line)
- def __iter__(self) -> typing.Generator[str, typing.Any, typing.Any]:
+ def __iter__(self) -> Generator[str, typing.Any, typing.Any]:
yield self.line
def writelines(self, __lines: Iterable[str]) -> None:
diff --git a/progressbar/utils.py b/progressbar/utils.py
index 6323ae8f..affb15c1 100644
--- a/progressbar/utils.py
+++ b/progressbar/utils.py
@@ -8,8 +8,9 @@
import os
import re
import sys
+import typing
+from collections.abc import Callable, Iterable, Iterator, Mapping
from types import TracebackType
-from typing import Iterable, Iterator
from python_utils import types
from python_utils.converters import scale_1024
@@ -18,9 +19,6 @@
from progressbar import base, env, terminal
-if types.TYPE_CHECKING:
- from .bar import ProgressBar, ProgressBarMixinBase
-
# Make sure these are available for import
assert timedelta_to_seconds is not None
assert get_terminal_size is not None
@@ -28,13 +26,54 @@
assert scale_1024 is not None
assert epoch is not None
-StringT = types.TypeVar('StringT', bound=types.StringTypes)
+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,
- default: types.Optional[types.Type[ValueError]] = ValueError,
-) -> int | float | None:
+ *deltas: None | datetime.timedelta | float | int,
+ default: typing.Any = ValueError,
+) -> typing.Any:
"""
Convert timedeltas and seconds as int to seconds as float while coalescing.
@@ -72,8 +111,7 @@ def deltas_to_seconds(
if default is ValueError:
raise ValueError('No valid deltas passed to `deltas_to_seconds`')
else:
- # mypy doesn't understand the `default is ValueError` check
- return default # type: ignore
+ return default
def no_color(value: StringT) -> StringT:
@@ -92,10 +130,17 @@ def no_color(value: StringT) -> StringT:
TypeError: `value` must be a string or bytes, got 123
"""
if isinstance(value, bytes):
- pattern: bytes = bytes(terminal.ESC, 'ascii') + b'\\[.*?[@-~]'
- return re.sub(pattern, b'', value) # type: ignore
+ # 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):
- return re.sub('\x1b\\[.*?[@-~]', '', value) # type: ignore
+ if '\x1b' not in value:
+ return value # type: ignore
+ return _ANSI_COLOR_RE.sub('', value) # type: ignore
else:
raise TypeError(f'`value` must be a string or bytes, got {value!r}')
@@ -118,14 +163,14 @@ class WrappingIO:
buffer: io.StringIO
target: base.IO
capturing: bool
- listeners: set
+ listeners: set[_ProgressListener]
needs_clear: bool = False
def __init__(
self,
target: base.IO,
capturing: bool = False,
- listeners: types.Optional[types.Set[ProgressBar]] = None,
+ listeners: set[_ProgressListener] | None = None,
) -> None:
self.buffer = io.StringIO()
self.target = target
@@ -154,10 +199,13 @@ def flush(self) -> None:
def _flush(self) -> None:
if value := self.buffer.getvalue():
self.flush()
- self.target.write(value)
+ # 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()
@@ -196,7 +244,7 @@ def seekable(self) -> bool:
def tell(self) -> int:
return self.target.tell()
- def truncate(self, size: types.Optional[int] = None) -> int:
+ def truncate(self, size: int | None = None) -> int:
return self.target.truncate(size)
def writable(self) -> bool:
@@ -229,9 +277,9 @@ class StreamWrapper:
stdout: base.TextIO | WrappingIO
stderr: base.TextIO | WrappingIO
- original_excepthook: types.Callable[
+ original_excepthook: Callable[
[
- types.Type[BaseException],
+ type[BaseException],
BaseException,
TracebackType | None,
],
@@ -239,9 +287,11 @@ class StreamWrapper:
]
wrapped_stdout: int = 0
wrapped_stderr: int = 0
+ wrapped_logging: int = 0
wrapped_excepthook: int = 0
+ logging_handlers: list[tuple[logging.StreamHandler[base.IO], base.IO]]
capturing: int = 0
- listeners: set
+ listeners: set[_ProgressListener]
def __init__(self) -> None:
self.stdout = self.original_stdout = sys.stdout
@@ -249,7 +299,9 @@ def __init__(self) -> None:
self.original_excepthook = sys.excepthook
self.wrapped_stdout = 0
self.wrapped_stderr = 0
+ self.wrapped_logging = 0
self.wrapped_excepthook = 0
+ self.logging_handlers = []
self.capturing = 0
self.listeners = set()
@@ -259,14 +311,14 @@ def __init__(self) -> None:
if env.env_flag('WRAP_STDERR', default=False): # pragma: no cover
self.wrap_stderr()
- def start_capturing(self, bar: ProgressBarMixinBase | None = None) -> None:
+ 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: ProgressBarMixinBase | None = None) -> None:
+ def stop_capturing(self, bar: _ProgressListener | None = None) -> None:
if bar: # pragma: no branch
with contextlib.suppress(KeyError):
self.listeners.remove(bar)
@@ -315,6 +367,80 @@ def wrap_stderr(self) -> WrappingIO:
return sys.stderr # type: ignore
+ def wrap_logging(self) -> None:
+ """Retarget stdout/stderr logging handlers to wrapped streams."""
+ self.wrapped_logging += 1
+ if self.wrapped_logging > 1:
+ return
+
+ wrapped_streams = {
+ self.original_stdout: self.stdout,
+ self.original_stderr: self.stderr,
+ sys.stdout: self.stdout,
+ sys.stderr: self.stderr,
+ }
+ restore_streams: dict[object, base.IO] = {}
+ if isinstance(self.stdout, WrappingIO):
+ restore_streams[self.stdout] = self.original_stdout
+ if isinstance(self.stderr, WrappingIO):
+ restore_streams[self.stderr] = self.original_stderr
+
+ seen: set[int] = set()
+ for logger_ in self._iter_loggers():
+ for handler in 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
@@ -337,15 +463,23 @@ def unwrap_stdout(self) -> None:
if self.wrapped_stdout > 1:
self.wrapped_stdout -= 1
else:
- sys.stdout = self.original_stdout
+ # 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:
- sys.stderr = self.original_stderr
+ # 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)
@@ -356,8 +490,8 @@ def flush(self) -> None:
if self.wrapped_stdout and isinstance(self.stdout, WrappingIO):
try:
self.stdout._flush()
- except io.UnsupportedOperation: # pragma: no cover
- self.wrapped_stdout = False
+ except io.UnsupportedOperation:
+ self.wrapped_stdout = 0
logger.warning(
'Disabling stdout redirection, %r is not seekable',
sys.stdout,
@@ -367,7 +501,7 @@ def flush(self) -> None:
try:
self.stderr._flush()
except io.UnsupportedOperation: # pragma: no cover
- self.wrapped_stderr = False
+ self.wrapped_stderr = 0
logger.warning(
'Disabling stderr redirection, %r is not seekable',
sys.stderr,
@@ -377,7 +511,7 @@ def excepthook(
self,
exc_type: type[BaseException],
exc_value: BaseException,
- exc_traceback: types.TracebackType | None,
+ exc_traceback: TracebackType | None,
) -> None:
self.original_excepthook(exc_type, exc_value, exc_traceback)
self.flush()
@@ -429,13 +563,13 @@ class AttributeDict(dict):
AttributeError: No such attribute: spam
"""
- def __getattr__(self, name: str) -> int:
+ 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: int) -> None:
+ def __setattr__(self, name: str, value: typing.Any) -> None:
self[name] = value
def __delattr__(self, name: str) -> None:
@@ -445,6 +579,5 @@ def __delattr__(self, name: str) -> None:
raise AttributeError(f'No such attribute: {name}')
-logger: logging.Logger = logging.getLogger(__name__)
streams = StreamWrapper()
atexit.register(streams.flush)
diff --git a/progressbar/widgets.py b/progressbar/widgets.py
index ffb201ef..73d33e7c 100644
--- a/progressbar/widgets.py
+++ b/progressbar/widgets.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import abc
+import collections.abc
import contextlib
import datetime
import functools
@@ -11,12 +12,12 @@
# `types` module
from typing import ClassVar
-from python_utils import containers, converters, types
+from python_utils import containers, converters
from . import algorithms, base, terminal, utils
from .terminal import colors
-if types.TYPE_CHECKING:
+if typing.TYPE_CHECKING:
from .bar import NumberT, ProgressBarMixinBase
logger = logging.getLogger(__name__)
@@ -25,13 +26,15 @@
MAX_TIME = datetime.time.max
MAX_DATETIME = datetime.datetime.max
-Data = types.Dict[str, types.Any]
-FormatString = typing.Optional[str]
+Data = dict[str, typing.Any]
+FormatString = str | None
T = typing.TypeVar('T')
-def string_or_lambda(input_):
+def string_or_lambda(
+ input_: str | collections.abc.Callable[..., str],
+) -> collections.abc.Callable[..., str]:
if isinstance(input_, str):
def render_input(progress, data, width):
@@ -42,7 +45,9 @@ def render_input(progress, data, width):
return input_
-def create_wrapper(wrapper):
+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('')
@@ -60,7 +65,8 @@ def create_wrapper(wrapper):
return None
if isinstance(wrapper, str):
- assert '{}' in wrapper, 'Expected string with {} for formatting'
+ 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 '
@@ -86,28 +92,45 @@ def wrap(*args: typing.Any, **kwargs: typing.Any):
return wrap
-def create_marker(marker, wrap=None):
- def _marker(progress, data, width):
- if (
- progress.max_value is not base.UnknownLength
- and progress.max_value > 0
- ):
- length = int(progress.value / progress.max_value * width)
- return marker * length
- else:
- return marker
-
+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):
- marker = converters.to_unicode(marker)
- # Ruff is silly at times... the format is not compatible with the check
- marker_length_error = 'Markers are required to be 1 char'
- assert utils.len_color(marker) == 1, marker_length_error
+ # 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
+
return wrapper(_marker, wrap)
else:
return wrapper(marker, wrap)
-class FormatWidgetMixin(abc.ABC):
+class FormatWidgetMixin:
"""Mixin to format widgets using a formatstring.
Variables available:
@@ -128,12 +151,15 @@ def __init__(
):
self.new_style = new_style
self.format = format
+ # 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: types.Optional[str] = None,
+ format: str | None = None,
) -> str:
return format or self.format
@@ -141,7 +167,7 @@ def __call__(
self,
progress: ProgressBarMixinBase,
data: Data,
- format: types.Optional[str] = None,
+ format: str | None = None,
) -> str:
"""Formats the widget into a string."""
format_ = self.get_format(progress, data, format)
@@ -159,7 +185,20 @@ def __call__(
raise
-class WidthWidgetMixin(abc.ABC):
+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.
@@ -187,6 +226,7 @@ class WidthWidgetMixin(abc.ABC):
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: ProgressBarMixinBase):
max_width = self.max_width
@@ -200,13 +240,13 @@ def check_size(self, progress: ProgressBarMixinBase):
class TGradientColors(typing.TypedDict):
- fg: types.Optional[terminal.OptionalColor | None]
- bg: types.Optional[terminal.OptionalColor | None]
+ fg: terminal.OptionalColor
+ bg: terminal.OptionalColor
class TFixedColors(typing.TypedDict):
- fg_none: types.Optional[terminal.Color | None]
- bg_none: types.Optional[terminal.Color | None]
+ fg_none: terminal.Color | None
+ bg_none: terminal.Color | None
class WidgetBase(WidthWidgetMixin, metaclass=abc.ABCMeta):
@@ -247,18 +287,18 @@ def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str:
progress - a reference to the calling ProgressBar
"""
- _fixed_colors: ClassVar[TFixedColors] = TFixedColors(
+ # 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: ClassVar[TGradientColors] = TGradientColors(
+ _gradient_colors: TGradientColors = TGradientColors(
fg=None,
bg=None,
)
- # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict()
- # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor | None]] = (
- # dict())
- _len: typing.Callable[[str | bytes], int] = len
+ _len: collections.abc.Callable[[str | bytes], int] = len
@functools.cached_property
def uses_colors(self):
@@ -286,11 +326,24 @@ def __init__(
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:
- self._fixed_colors.update(fixed_colors)
+ merged_fixed = type(self)._fixed_colors.copy()
+ merged_fixed.update(fixed_colors)
+ self._fixed_colors = merged_fixed
if gradient_colors is not None:
- self._gradient_colors.update(gradient_colors)
+ 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
@@ -342,7 +395,7 @@ class FormatLabel(FormatWidgetMixin, WidgetBase):
"""
- mapping: ClassVar[types.Dict[str, types.Tuple[str, types.Any]]] = dict(
+ mapping: ClassVar[dict[str, tuple[str, typing.Any]]] = dict(
finished=('end_time', None),
last_update=('last_update_time', None),
max=('max_value', None),
@@ -353,20 +406,25 @@ class FormatLabel(FormatWidgetMixin, WidgetBase):
)
def __init__(self, format: str, **kwargs: typing.Any):
- FormatWidgetMixin.__init__(self, format=format, **kwargs)
- WidgetBase.__init__(self, **kwargs)
+ super().__init__(format=format, **kwargs)
def __call__(
self,
progress: ProgressBarMixinBase,
data: Data,
- format: types.Optional[str] = None,
+ format: str | None = None,
):
for name, (key, transform) in self.mapping.items():
- with contextlib.suppress(KeyError, ValueError, IndexError):
- if transform is None:
- data[name] = data[key]
- else:
+ # 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])
return FormatWidgetMixin.__call__(self, progress, data, format)
@@ -378,11 +436,13 @@ class Timer(FormatLabel, TimeSensitiveWidgetBase):
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')
- FormatLabel.__init__(self, format=format, **kwargs)
- TimeSensitiveWidgetBase.__init__(self, **kwargs)
+ super().__init__(format=format, **kwargs)
# This is exposed as a static method for backwards compatibility
format_time = staticmethod(utils.format_time)
@@ -422,13 +482,13 @@ class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta):
def __init__(
self,
- samples=datetime.timedelta(seconds=2),
+ samples: datetime.timedelta | int = datetime.timedelta(seconds=2),
key_prefix=None,
**kwargs,
):
self.samples = samples
self.key_prefix = (key_prefix or self.__class__.__name__) + '_'
- TimeSensitiveWidgetBase.__init__(self, **kwargs)
+ super().__init__(**kwargs)
def get_sample_times(self, progress: ProgressBarMixinBase, data: Data):
return progress.extra.setdefault(
@@ -463,12 +523,7 @@ def __call__(
if isinstance(self.samples, datetime.timedelta):
minimum_time = progress.last_update_time - self.samples
- minimum_value = sample_values[-1]
- while (
- sample_times[2:]
- and minimum_time > sample_times[1]
- and minimum_value > sample_values[1]
- ):
+ while sample_times[2:] and minimum_time > sample_times[1]:
sample_times.pop(0)
sample_values.pop(0)
elif len(sample_times) > self.samples:
@@ -497,10 +552,16 @@ def __init__(
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')
- Timer.__init__(self, **kwargs)
+ # ``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
@@ -511,43 +572,72 @@ def _calculate_eta(
self,
progress: ProgressBarMixinBase,
data: Data,
- value,
- elapsed,
- ):
+ 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
+ # 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 __call__(
+ def _resolve_value_elapsed(
self,
progress: ProgressBarMixinBase,
data: Data,
- value=None,
- elapsed=None,
+ value,
+ elapsed,
):
- """Updates the widget to show the ETA or total time when finished."""
+ """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']
+ 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
- try:
+ 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,
)
- except TypeError:
- data['eta_seconds'] = None
- eta_na = True
data['eta'] = None
if data['eta_seconds']:
@@ -575,9 +665,9 @@ def _calculate_eta(
self,
progress: ProgressBarMixinBase,
data: Data,
- value,
- elapsed,
- ):
+ value: float,
+ elapsed: datetime.timedelta | None,
+ ) -> datetime.datetime:
eta_seconds = ETA._calculate_eta(self, progress, data, value, elapsed)
now = datetime.datetime.now()
try:
@@ -592,8 +682,7 @@ def __init__(
format='Estimated finish time: %(eta)s',
**kwargs,
):
- ETA.__init__(
- self,
+ super().__init__(
format_not_started=format_not_started,
format_finished=format_finished,
format=format,
@@ -605,7 +694,9 @@ class AdaptiveETA(ETA, SamplesMixin):
"""WidgetBase which attempts to estimate the time of arrival.
Uses a sampled average of the speed based on the 10 last updates.
- Very convenient for resuming the progress halfway.
+ 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`.
"""
exponential_smoothing: bool
@@ -619,8 +710,7 @@ def __init__(
):
self.exponential_smoothing = exponential_smoothing
self.exponential_smoothing_factor = exponential_smoothing_factor
- ETA.__init__(self, **kwargs)
- SamplesMixin.__init__(self, **kwargs)
+ super().__init__(**kwargs)
def __call__(
self,
@@ -665,9 +755,9 @@ def __init__(
):
self.smoothing_parameters = smoothing_parameters or {}
self.smoothing_algorithm = smoothing_algorithm(
- **(self.smoothing_parameters or {}),
+ **self.smoothing_parameters,
)
- ETA.__init__(self, **kwargs)
+ super().__init__(**kwargs)
def __call__(
self,
@@ -676,13 +766,10 @@ def __call__(
value=None,
elapsed=None,
):
- if value is None: # pragma: no branch
- value = data['value']
-
- if elapsed is None: # pragma: no branch
- elapsed = data['time_elapsed']
-
- self.smoothing_algorithm.update(value, elapsed)
+ 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)
@@ -705,14 +792,13 @@ def __init__(
self.variable = variable
self.unit = unit
self.prefixes = prefixes
- FormatWidgetMixin.__init__(self, format=format, **kwargs)
- WidgetBase.__init__(self, **kwargs)
+ super().__init__(format=format, **kwargs)
def __call__(
self,
progress: ProgressBarMixinBase,
data: Data,
- format: types.Optional[str] = None,
+ format: str | None = None,
):
value = data[self.variable]
if value is not None:
@@ -743,8 +829,7 @@ def __init__(
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
@@ -777,10 +862,11 @@ def __call__(
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,
@@ -798,8 +884,7 @@ class AdaptiveTransferSpeed(FileTransferSpeed, SamplesMixin):
"""Widget for showing the transfer speed based on the last X samples."""
def __init__(self, **kwargs: typing.Any):
- FileTransferSpeed.__init__(self, **kwargs)
- SamplesMixin.__init__(self, **kwargs)
+ super().__init__(**kwargs)
def __call__(
self,
@@ -824,25 +909,30 @@ class AnimatedMarker(TimeSensitiveWidgetBase):
def __init__(
self,
- markers='|/-\\',
- default=None,
- fill='',
- marker_wrap=None,
- fill_wrap=None,
- **kwargs,
+ 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]
self.fill_wrap = create_wrapper(fill_wrap)
self.fill = create_marker(fill, self.fill_wrap) if fill else None
- WidgetBase.__init__(self, **kwargs)
+ 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
marker = self.markers[data['updates'] % len(self.markers)]
@@ -870,7 +960,8 @@ def __call__(self, progress: ProgressBarMixinBase, data: Data, width=None):
return fill + marker # type: ignore
-# Alias for backwards compatibility
+# Legacy alias for `AnimatedMarker`, kept for backwards compatibility. Kept as
+# a plain alias (no DeprecationWarning) until the next major version.
RotatingMarker = AnimatedMarker
@@ -878,8 +969,9 @@ class Counter(FormatWidgetMixin, WidgetBase):
"""Displays the current count."""
def __init__(self, format='%(value)d', **kwargs: typing.Any):
- FormatWidgetMixin.__init__(self, format=format, **kwargs)
- WidgetBase.__init__(self, format=format, **kwargs)
+ # ``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,
@@ -891,19 +983,15 @@ def __call__(
class ColoredMixin:
- _fixed_colors: ClassVar[TFixedColors] = TFixedColors(
+ # See ``WidgetBase``: class-level defaults, overridable per instance.
+ _fixed_colors: TFixedColors = TFixedColors(
fg_none=colors.yellow,
bg_none=None,
)
- _gradient_colors: ClassVar[TGradientColors] = TGradientColors(
+ _gradient_colors: TGradientColors = TGradientColors(
fg=colors.gradient,
bg=None,
)
- # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict(
- # fg_none=colors.yellow, bg_none=None)
- # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor |
- # None]] = dict(fg=colors.gradient,
- # bg=None)
class Percentage(FormatWidgetMixin, ColoredMixin, WidgetBase):
@@ -913,8 +1001,7 @@ def __init__(
self, format='%(percentage)3d%%', na='N/A%%', **kwargs: typing.Any
):
self.na = na
- FormatWidgetMixin.__init__(self, format=format, **kwargs)
- WidgetBase.__init__(self, format=format, **kwargs)
+ super().__init__(format=format, **kwargs)
def get_format(
self,
@@ -932,23 +1019,72 @@ def get_format(
return self._apply_colors(output, data)
+UNIT_PREFIXES = ('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi')
+DEFAULT_UNIT = object()
+
+
+def format_unit_value(value, unit='it', unit_scale=False) -> str:
+ if value in (None, base.UnknownLength):
+ return 'N/A'
+ if unit_scale:
+ scaled, power = utils.scale_1024(float(value), len(UNIT_PREFIXES))
+ prefix = UNIT_PREFIXES[int(power)]
+ return f'{scaled:.1f} {prefix}{unit}'
+ if isinstance(value, float):
+ return f'{value:g} {unit}'
+ return f'{value} {unit}'
+
+
+class UnitProgress(WidgetBase):
+ """Displays progress as a count with an optional unit and 1024 scaling."""
+
+ def __init__(
+ self,
+ unit=DEFAULT_UNIT,
+ unit_scale=DEFAULT_UNIT,
+ **kwargs: typing.Any,
+ ):
+ self.use_progress_unit = unit is DEFAULT_UNIT
+ self.use_progress_unit_scale = unit_scale is DEFAULT_UNIT
+ self.unit: str = (
+ 'it' if unit is DEFAULT_UNIT else typing.cast(str, unit)
+ )
+ self.unit_scale: bool = (
+ False
+ if unit_scale is DEFAULT_UNIT
+ else typing.cast(bool, unit_scale)
+ )
+ WidgetBase.__init__(self, **kwargs)
+
+ def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str:
+ unit = typing.cast(str, data.get('unit', self.unit))
+ unit_scale = typing.cast(bool, data.get('unit_scale', self.unit_scale))
+ if not self.use_progress_unit:
+ unit = self.unit
+ if not self.use_progress_unit_scale:
+ unit_scale = self.unit_scale
+ value = format_unit_value(data.get('value'), unit, unit_scale)
+ max_value = format_unit_value(data.get('max_value'), unit, unit_scale)
+ return f'{value} of {max_value}'
+
+
class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase):
"""Returns progress as a count of the total (e.g.: "5 of 47")."""
max_width_cache: dict[
str
| tuple[
- NumberT | types.Type[base.UnknownLength] | None,
- NumberT | types.Type[base.UnknownLength] | None,
+ NumberT | type[base.UnknownLength] | None,
+ NumberT | type[base.UnknownLength] | None,
],
- types.Optional[int],
+ int | None,
]
DEFAULT_FORMAT = '%(value_s)s of %(max_value_s)s'
def __init__(self, format=DEFAULT_FORMAT, **kwargs: typing.Any):
- FormatWidgetMixin.__init__(self, format=format, **kwargs)
- WidgetBase.__init__(self, format=format, **kwargs)
+ 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
@@ -980,7 +1116,7 @@ def __call__(
# Guess the maximum width from the min and max value
key = progress.min_value, progress.max_value
- max_width: types.Optional[int] = self.max_width_cache.get(
+ max_width: int | None = self.max_width_cache.get(
key,
self.max_width,
)
@@ -1013,8 +1149,8 @@ def __call__(
class Bar(AutoWidthWidgetBase):
"""A progress bar which stretches to fill the line."""
- fg: terminal.OptionalColor | None = colors.gradient
- bg: terminal.OptionalColor | None = None
+ fg: terminal.OptionalColor = colors.gradient
+ bg: terminal.OptionalColor = None
def __init__(
self,
@@ -1042,19 +1178,34 @@ def __init__(
self.fill = string_or_lambda(fill)
self.fill_left = fill_left
- AutoWidthWidgetBase.__init__(self, **kwargs)
+ 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=True,
+ color: bool = True,
):
"""Updates the progress bar and its subcomponents."""
- left = converters.to_unicode(self.left(progress, data, width))
- right = converters.to_unicode(self.right(progress, data, width))
- width -= progress.custom_len(left) + progress.custom_len(right)
+ 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))
@@ -1092,8 +1243,7 @@ def __init__(
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,
+ super().__init__(
marker=marker,
left=left,
right=right,
@@ -1113,12 +1263,10 @@ def __call__(
progress: ProgressBarMixinBase,
data: Data,
width: int = 0,
- color=True,
+ color: bool = True,
):
"""Updates the progress bar and its subcomponents."""
- left = converters.to_unicode(self.left(progress, data, width))
- right = converters.to_unicode(self.right(progress, data, width))
- width -= progress.custom_len(left) + progress.custom_len(right)
+ 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))
@@ -1142,28 +1290,33 @@ def __call__(
class FormatCustomText(FormatWidgetMixin, WidgetBase):
- mapping: types.Dict[str, types.Any] = dict() # noqa: RUF012
+ mapping: dict[str, typing.Any] = dict() # noqa: RUF012
copy = False
def __init__(
self,
format: str,
- mapping: types.Optional[types.Dict[str, types.Any]] = None,
+ mapping: dict[str, typing.Any] | None = None,
**kwargs,
):
- self.format = format
- self.mapping = mapping or self.mapping
- FormatWidgetMixin.__init__(self, format=format, **kwargs)
- WidgetBase.__init__(self, **kwargs)
-
- def update_mapping(self, **mapping: types.Dict[str, types.Any]):
+ # ``self.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: types.Optional[str] = None,
+ format: str | None = None,
):
return FormatWidgetMixin.__call__(
self,
@@ -1173,7 +1326,7 @@ def __call__(
)
-class VariableMixin:
+class VariableMixin(_WidgetKwargsSink):
"""Mixin to display a custom user variable."""
def __init__(self, name, **kwargs: typing.Any):
@@ -1182,6 +1335,39 @@ def __init__(self, name, **kwargs: typing.Any):
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):
@@ -1196,8 +1382,8 @@ class MultiRangeBar(Bar, VariableMixin):
"""
def __init__(self, name, markers, **kwargs: typing.Any):
- VariableMixin.__init__(self, name)
- Bar.__init__(self, **kwargs)
+ # ``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):
@@ -1208,12 +1394,10 @@ def __call__(
progress: ProgressBarMixinBase,
data: Data,
width: int = 0,
- color=True,
+ color: bool = True,
):
"""Updates the progress bar and its subcomponents."""
- left = converters.to_unicode(self.left(progress, data, width))
- right = converters.to_unicode(self.right(progress, data, width))
- width -= progress.custom_len(left) + progress.custom_len(right)
+ left, right, width = self._render_borders(progress, data, width)
values = self.get_values(progress, data)
values_sum = sum(values)
@@ -1221,9 +1405,10 @@ def __call__(
middle = ''
values_accumulated = 0
width_accumulated = 0
- for marker, value in zip(self.markers, values):
+ for marker, value in zip(self.markers, values, strict=False):
marker = converters.to_unicode(marker(progress, data, width))
- assert progress.custom_len(marker) == 1
+ 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)
@@ -1232,7 +1417,10 @@ def __call__(
middle += item_width * marker
else:
fill = converters.to_unicode(self.fill(progress, data, width))
- assert progress.custom_len(fill) == 1
+ 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
@@ -1247,8 +1435,7 @@ def __init__(
markers=' ▁▂▃▄▅▆▇█',
**kwargs,
):
- MultiRangeBar.__init__(
- self,
+ super().__init__(
name=name,
markers=list(reversed(markers)),
**kwargs,
@@ -1258,9 +1445,13 @@ def get_values(self, progress: ProgressBarMixinBase, data: Data):
ranges = [0.0] * len(self.markers)
for value in data['variables'][self.name] or []:
if not isinstance(value, (int, float)):
- # Progress is (value, max)
+ # Progress is (value, max). A zero maximum means the total
+ # is not known (yet), so no progress can be shown.
progress_value, progress_max = value
- value = float(progress_value) / float(progress_max)
+ if progress_max:
+ value = float(progress_value) / float(progress_max)
+ else:
+ value = 0.0
if not 0 <= value <= 1:
raise ValueError(
@@ -1325,7 +1516,7 @@ def __init__(
self.left = string_or_lambda(left)
self.right = string_or_lambda(right)
- AutoWidthWidgetBase.__init__(self, **kwargs)
+ super().__init__(**kwargs)
def __call__(
self,
@@ -1333,6 +1524,10 @@ def __call__(
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)
@@ -1366,8 +1561,7 @@ class FormatLabelBar(FormatLabel, Bar):
"""A bar which has a formatted label in the center."""
def __init__(self, format, **kwargs: typing.Any):
- FormatLabel.__init__(self, format, **kwargs)
- Bar.__init__(self, **kwargs)
+ super().__init__(format=format, **kwargs)
def __call__( # type: ignore
self,
@@ -1408,8 +1602,7 @@ class PercentageLabelBar(Percentage, FormatLabelBar):
def __init__(
self, format='%(percentage)2d%%', na='N/A%%', **kwargs: typing.Any
):
- Percentage.__init__(self, format, na=na, **kwargs)
- FormatLabelBar.__init__(self, format, **kwargs)
+ super().__init__(format=format, na=na, **kwargs)
def __call__( # type: ignore
self,
@@ -1433,17 +1626,17 @@ def __init__(
**kwargs,
):
"""Creates a Variable associated with the given name."""
- self.format = format
self.width = width
self.precision = precision
- VariableMixin.__init__(self, name=name)
- WidgetBase.__init__(self, **kwargs)
+ # 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: types.Optional[str] = None,
+ format: str | None = None,
):
value = data['variables'][self.name]
context = data.copy()
@@ -1470,7 +1663,11 @@ def __call__(
class DynamicMessage(Variable):
- """Kept for backwards compatibility, please use `Variable` instead."""
+ """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):
@@ -1485,14 +1682,13 @@ def __init__(
**kwargs,
):
self.microseconds = microseconds
- FormatWidgetMixin.__init__(self, format=format, **kwargs)
- TimeSensitiveWidgetBase.__init__(self, **kwargs)
+ super().__init__(format=format, **kwargs)
def __call__(
self,
progress: ProgressBarMixinBase,
data: Data,
- format: types.Optional[str] = None,
+ format: str | None = None,
):
data['current_time'] = self.current_time()
data['current_datetime'] = self.current_datetime()
@@ -1540,6 +1736,13 @@ class JobStatusBar(Bar, VariableMixin):
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,
@@ -1556,12 +1759,13 @@ def __init__(
failure_marker='X',
**kwargs,
):
- VariableMixin.__init__(self, name)
- self.name = name
+ # 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 = []
- self.left = string_or_lambda(left)
- self.right = string_or_lambda(right)
- self.fill = string_or_lambda(fill)
+ # 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
@@ -1569,8 +1773,10 @@ def __init__(
self.failure_bg_color = failure_bg_color
self.failure_marker = failure_marker
- Bar.__init__(
- self,
+ # ``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,
@@ -1578,16 +1784,19 @@ def __init__(
**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=True,
+ color: bool = True,
):
- left = converters.to_unicode(self.left(progress, data, width))
- right = converters.to_unicode(self.right(progress, data, width))
- width -= progress.custom_len(left) + progress.custom_len(right)
+ left, right, width = self._render_borders(progress, data, width)
status: str | bool | None = data['variables'].get(self.name)
@@ -1610,12 +1819,21 @@ def __call__(
if bg_color: # pragma: no cover
marker = bg_color.bg(marker)
- self.job_markers.append(marker)
- marker = ''.join(self.job_markers)
+ 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 * width, data)
+ fill = self._apply_colors(fill * max(width, 0), data)
if self.fill_left: # pragma: no branch
marker += fill
diff --git a/pyproject.toml b/pyproject.toml
index c9ee86e8..54f1aaa3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,20 @@
+[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]
-authors = [{ name = 'Rick van Hattem (Wolph)', email = 'wolph@wol.ph' }]
+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',
@@ -33,10 +46,6 @@ keywords = [
'time',
'visual',
]
-license = { text = 'BSD-3-Clause' }
-name = 'progressbar2'
-requires-python = '>=3.8'
-
classifiers = [
'Development Status :: 5 - Production/Stable',
'Development Status :: 6 - Mature',
@@ -70,8 +79,9 @@ classifiers = [
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
- 'Programming Language :: Python :: 3.8',
- 'Programming Language :: Python :: 3.9',
+ 'Programming Language :: Python :: 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',
@@ -83,8 +93,8 @@ classifiers = [
'Topic :: Office/Business',
'Topic :: Other/Nonlisted Topic',
'Topic :: Software Development :: Build Tools',
- 'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Software Development :: Libraries',
+ 'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Software Development :: Pre-processors',
'Topic :: Software Development :: User Interfaces',
'Topic :: System :: Installation/Setup',
@@ -93,79 +103,58 @@ classifiers = [
'Topic :: System :: Shells',
'Topic :: Terminals',
'Topic :: Utilities',
+ 'Typing :: Typed',
]
-description = 'A Python Progressbar library to provide visual (yet text based) progress to long running operations.'
-readme = 'README.rst'
+requires-python = '>=3.10'
dependencies = ['python-utils >= 3.8.1']
-[tool.setuptools.dynamic]
-version = { attr = 'progressbar.__about__.__version__' }
-
-[tool.setuptools.packages.find]
-exclude = ['docs*', 'tests*']
-
-[tool.setuptools]
-include-package-data = true
+[project.urls]
+bugs = 'https://github.com/wolph/python-progressbar/issues'
+documentation = 'https://progressbar-2.readthedocs.io/en/latest/'
+repository = 'https://github.com/wolph/python-progressbar/'
[project.scripts]
progressbar = 'progressbar.__main__:main'
[project.optional-dependencies]
-docs = ['sphinx>=1.8.5', 'sphinx-autodoc-typehints>=1.6.0']
+# 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',
- 'flake8>=3.7.7',
'freezegun>=0.3.11',
'pytest-cov>=2.6.1',
- 'pytest-mypy',
'pytest>=4.6.9',
- 'sphinx>=1.8.5',
'pywin32; sys_platform == "win32"',
]
-[project.urls]
-bugs = 'https://github.com/wolph/python-progressbar/issues'
-documentation = 'https://progressbar-2.readthedocs.io/en/latest/'
-repository = 'https://github.com/wolph/python-progressbar/'
-
-[build-system]
-build-backend = 'setuptools.build_meta'
-requires = ['setuptools', 'setuptools-scm']
+[dependency-groups]
+dev = ['progressbar2[docs,tests]']
[tool.codespell]
skip = '*/htmlcov,./docs/_build,*.asc'
-
ignore-words-list = 'datas,numbert'
-[tool.black]
-line-length = 79
-skip-string-normalization = true
-
-[tool.mypy]
-packages = ['progressbar', 'tests']
-exclude = [
- '^docs$',
- '^tests/original_examples.py$',
- '^examples.py$',
-]
-
[tool.coverage.run]
branch = true
-source = [
- 'progressbar',
- 'tests',
-]
+source = ['progressbar', 'tests']
omit = [
'*/mock/*',
'*/nose/*',
'.tox/*',
'*/os_specific/*',
]
+
[tool.coverage.paths]
-source = [
- 'progressbar',
-]
+source = ['progressbar']
[tool.coverage.report]
fail_under = 100
@@ -180,15 +169,26 @@ exclude_lines = [
'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']
+include = ['progressbar']
+exclude = ['examples', '.tox']
+ignore = ['docs']
strict = [
'progressbar/algorithms.py',
'progressbar/env.py',
@@ -213,6 +213,15 @@ strict = [
reportIncompatibleMethodOverride = false
reportUnnecessaryIsInstance = false
reportUnnecessaryCast = false
-reportUnnecessaryTypeAssertion = false
reportUnnecessaryComparison = false
reportUnnecessaryContains = false
+
+
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.dynamic]
+version = { attr = 'progressbar.__about__.__version__' }
+
+[tool.setuptools.packages.find]
+exclude = ['docs*', 'tests*']
diff --git a/pytest.ini b/pytest.ini
index 08a11301..6cec5595 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,21 +1,19 @@
[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=html
- --cov-report=term-missing
- --cov-report=xml
- --cov-config=./pyproject.toml
- --no-cov-on-fail
--doctest-modules
norecursedirs =
.*
_*
build
+ benchmarks
dist
docs
progressbar/terminal/os_specific
diff --git a/ruff.toml b/ruff.toml
index f6efc61d..eb467554 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -1,16 +1,23 @@
# We keep the ruff configuration separate so it can easily be shared across
# all projects
-target-version = 'py38'
+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',
]
-lint.ignore = [
+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
@@ -28,12 +35,14 @@ lint.ignore = [
'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
]
-line-length = 79
-lint.select = [
+
+select = [
'A', # flake8-builtins
'ASYNC', # flake8 async checker
'B', # flake8-bugbear
@@ -104,4 +113,3 @@ 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'''
+'''
+
+
+def render_svg(path: Path, title: str, frames: list[list[str]]) -> None:
+ svg = svg_document(title, frames)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(svg, encoding='utf-8')
+
+
+def check_svg(path: Path, expected: str) -> None:
+ if not path.exists():
+ raise SystemExit(f'missing generated asset: {path}')
+ if path.read_text(encoding='utf-8') != expected:
+ raise SystemExit(f'outdated generated asset: {path}')
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--check', action='store_true')
+ args = parser.parse_args()
+ for demo in DEMOS:
+ output = STATIC_DIR / f'progressbar-{demo.name}.svg'
+ frames = capture_demo(demo)
+ if args.check:
+ check_svg(output, svg_document(demo.title, frames))
+ else:
+ render_svg(output, demo.title, frames)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tests/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 787e643e..bad96e61 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,11 @@
from __future__ import annotations
+import collections.abc
import logging
import time
import timeit
-from datetime import datetime
+import typing
+from datetime import datetime, timezone
import freezegun
import pytest
@@ -18,14 +20,33 @@
}
-def pytest_configure(config) -> None:
+def pytest_configure(config: pytest.Config) -> None:
logging.basicConfig(
level=LOG_LEVELS.get(config.option.verbose, logging.DEBUG),
)
@pytest.fixture(autouse=True)
-def small_interval(monkeypatch) -> None:
+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,
@@ -36,11 +57,21 @@ def small_interval(monkeypatch) -> None:
@pytest.fixture(autouse=True)
-def sleep_faster(monkeypatch):
- # The timezone offset in seconds, add 10 seconds to make sure we don't
- # accidentally get the wrong hour
- offset_seconds = (datetime.now() - datetime.utcnow()).seconds + 10
- offset_hours = int(offset_seconds / 3600)
+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:
diff --git a/tests/original_examples.py b/tests/original_examples.py
index 7f1db168..30d6c0f6 100644
--- a/tests/original_examples.py
+++ b/tests/original_examples.py
@@ -26,7 +26,7 @@
def example(fn):
try:
- name = 'Example %d' % int(fn.__name__[7:])
+ name = f'Example {int(fn.__name__[7:]):d}'
except Exception:
name = fn.__name__
diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py
index a6cc6467..4ce1364b 100644
--- a/tests/test_algorithms.py
+++ b/tests/test_algorithms.py
@@ -8,7 +8,7 @@
def test_ema_initialization() -> None:
ema = algorithms.ExponentialMovingAverage()
assert ema.alpha == 0.5
- assert ema.value == 0
+ assert ema.value is None
@pytest.mark.parametrize(
@@ -25,7 +25,10 @@ def test_ema_initialization() -> None:
],
)
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
@@ -33,8 +36,8 @@ def test_ema_update(alpha, new_value: float, expected) -> None:
def test_dema_initialization() -> None:
dema = algorithms.DoubleExponentialMovingAverage()
assert dema.alpha == 0.5
- assert dema.ema1 == 0
- assert dema.ema2 == 0
+ assert dema.ema1 is None
+ assert dema.ema2 is None
@pytest.mark.parametrize(
@@ -50,9 +53,28 @@ def test_dema_initialization() -> None:
],
)
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 == expected
+ 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_color.py b/tests/test_color.py
index 4a368af4..bcb30716 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import os
-from typing import ClassVar
import pytest
@@ -97,6 +96,42 @@ def test_color_support_from_env(monkeypatch, variable, value) -> None:
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',
[
@@ -133,7 +168,7 @@ def test_enable_colors_flags() -> None:
class _TestFixedColorSupport(progressbar.widgets.WidgetBase):
- _fixed_colors: ClassVar[widgets.TFixedColors] = widgets.TFixedColors(
+ _fixed_colors: widgets.TFixedColors = widgets.TFixedColors(
fg_none=progressbar.widgets.colors.yellow,
bg_none=None,
)
@@ -143,11 +178,9 @@ def __call__(self, *args, **kwargs) -> None:
class _TestFixedGradientSupport(progressbar.widgets.WidgetBase):
- _gradient_colors: ClassVar[widgets.TGradientColors] = (
- widgets.TGradientColors(
- fg=progressbar.widgets.colors.gradient,
- bg=None,
- )
+ _gradient_colors: widgets.TGradientColors = widgets.TGradientColors(
+ fg=progressbar.widgets.colors.gradient,
+ bg=None,
)
def __call__(self, *args, **kwargs) -> None:
@@ -276,6 +309,36 @@ 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',
[
@@ -408,5 +471,124 @@ def test_ansi_color(monkeypatch) -> None:
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
index b0a272e4..ce9f24ca 100644
--- a/tests/test_custom_widgets.py
+++ b/tests/test_custom_widgets.py
@@ -93,3 +93,39 @@ def test_format_custom_text_widget() -> None:
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_transfer_bar.py b/tests/test_data_transfer_bar.py
index 7e5cfcce..e8f5c577 100644
--- a/tests/test_data_transfer_bar.py
+++ b/tests/test_data_transfer_bar.py
@@ -1,3 +1,5 @@
+import io
+
import progressbar
from progressbar import DataTransferBar
@@ -14,3 +16,16 @@ def test_unknown_length() -> None:
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_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
index 5953284b..f2c36b04 100644
--- a/tests/test_failure.py
+++ b/tests/test_failure.py
@@ -1,9 +1,13 @@
+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:
@@ -138,3 +142,49 @@ 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_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_job_status.py b/tests/test_job_status.py
index 778b6ce3..9747feac 100644
--- a/tests/test_job_status.py
+++ b/tests/test_job_status.py
@@ -1,8 +1,10 @@
+import io
import time
import pytest
import progressbar
+from progressbar import utils
@pytest.mark.parametrize(
@@ -20,3 +22,66 @@ def test_status(status) -> None:
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_monitor_progress.py b/tests/test_monitor_progress.py
index 4f99df90..6f883487 100644
--- a/tests/test_monitor_progress.py
+++ b/tests/test_monitor_progress.py
@@ -85,10 +85,10 @@ def test_list_example(testdir) -> None:
pprint.pprint(result.stderr.lines, width=70)
result.stderr.fnmatch_lines([
' 0% (0 of 9) | | Elapsed Time: ?:00:00 ETA: --:--:--',
- ' 11% (1 of 9) |# | Elapsed Time: ?:00:01 ETA: ?:00:08',
- ' 22% (2 of 9) |## | Elapsed Time: ?:00:02 ETA: ?:00:07',
- ' 33% (3 of 9) |#### | Elapsed Time: ?:00:03 ETA: ?:00:06',
- ' 44% (4 of 9) |##### | Elapsed Time: ?:00:04 ETA: ?:00:05',
+ ' 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',
@@ -114,7 +114,7 @@ def test_generator_example(testdir) -> None:
pprint.pprint(result.stderr.lines, width=70)
lines = [
- r'[/\\|\-]\s+\|\s*#\s*\| %(i)d Elapsed Time: \d:00:%(i)02d' % dict(i=i)
+ fr'[/\\|\-]\s+\|\s*#\s*\| {i:d} Elapsed Time: \d:00:{i:02d}'
for i in range(9)
]
result.stderr.re_match_lines(lines)
@@ -144,14 +144,14 @@ def test_rapid_updates(testdir) -> None:
result.stderr.fnmatch_lines(
[
' 0% (0 of 10) | | Elapsed Time: 0:00:00 ETA: --:--:--',
- ' 10% (1 of 10) | | Elapsed Time: 0:00:01 ETA: 0:00:09',
- ' 20% (2 of 10) |# | Elapsed Time: 0:00:02 ETA: 0:00:08',
- ' 30% (3 of 10) |# | Elapsed Time: 0:00:03 ETA: 0:00:07',
- ' 40% (4 of 10) |## | Elapsed Time: 0:00:04 ETA: 0:00:06',
- ' 50% (5 of 10) |### | Elapsed Time: 0:00:05 ETA: 0:00:05',
- ' 60% (6 of 10) |### | Elapsed Time: 0:00:07 ETA: 0:00:04',
- ' 70% (7 of 10) |#### | Elapsed Time: 0:00:09 ETA: 0:00:03',
- ' 80% (8 of 10) |#### | Elapsed Time: 0:00:11 ETA: 0:00:02',
+ ' 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',
],
@@ -200,7 +200,6 @@ def test_line_breaks(testdir) -> None:
' 60%|################################ |',
' 80%|########################################### |',
'100%|######################################################|',
- '100%|######################################################|',
),
)
@@ -224,8 +223,6 @@ def test_no_line_breaks(testdir) -> None:
' 60%|################################ |',
' 80%|########################################### |',
'100%|######################################################|',
- '',
- '100%|######################################################|',
]
@@ -248,8 +245,6 @@ def test_percentage_label_bar(testdir) -> None:
'|###########################60%#### |',
'|###########################80%################ |',
'|###########################100%###########################|',
- '',
- '|###########################100%###########################|',
]
@@ -272,8 +267,6 @@ def test_granular_bar(testdir) -> None:
'|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOo |',
'|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO. |',
'|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO|',
- '',
- '|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO|',
]
@@ -287,10 +280,10 @@ def test_colors(testdir) -> None:
testdir.makepyfile(_create_script(enable_colors=True, **kwargs)),
)
pprint.pprint(result.stderr.lines, width=70)
- assert result.stderr.lines == ['\x1b[92mgreen\x1b[0m'] * 3
+ 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'] * 3
+ assert result.stderr.lines == ['green'] * 2
diff --git a/tests/test_multibar.py b/tests/test_multibar.py
index 84484200..9b6d66fd 100644
--- a/tests/test_multibar.py
+++ b/tests/test_multibar.py
@@ -1,3 +1,5 @@
+import contextlib
+import io
import random
import threading
import time
@@ -137,6 +139,9 @@ def test_multibar_show_finished() -> None:
bar.update(i)
time.sleep(SLEEP)
+ # The context manager waits for all bars to finish
+ bar.finish()
+
multibar.render(force=True)
@@ -158,35 +163,100 @@ def test_multibar_empty_key() -> None:
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):
+ 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 up to 0.1 seconds
- time.sleep(random.random() * 0.1)
+ # 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 random.random() < probability:
+ 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, sometimes and always
- threading.Thread(target=print_sometimes, args=(bar, 0)).start()
- threading.Thread(target=print_sometimes, args=(bar, 0.5)).start()
- threading.Thread(target=print_sometimes, args=(bar, 1)).start()
+ # 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)
- multibar.update(force=True, flush=False)
- multibar.update(force=True, flush=True)
+ # 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:
@@ -214,6 +284,100 @@ def test_multibar_finished() -> None:
multibar.render(force=True)
+def test_multibar_render_writes_started_bar_text() -> None:
+ fd = io.StringIO()
+ multibar = progressbar.MultiBar(
+ fd=fd,
+ initial_format=None,
+ finished_format=None,
+ remove_finished=None,
+ sort_reverse=False,
+ total=3,
+ term_width=64,
+ )
+ build = multibar['build']
+ test = multibar['test']
+ multibar.render(force=True, flush=True)
+ fd.seek(0)
+ fd.truncate(0)
+
+ build.update(1, force=True)
+ test.update(1, force=True)
+ multibar.render(force=True, flush=True)
+
+ output = fd.getvalue()
+ assert 'build' in output
+ assert 'test' in output
+ assert '(1 of 3)' in output
+
+
+def test_multibar_wraps_pre_labeled_bar_stream() -> None:
+ fd = io.StringIO()
+ multibar = progressbar.MultiBar(
+ fd=fd,
+ initial_format=None,
+ finished_format=None,
+ remove_finished=None,
+ sort_reverse=False,
+ total=3,
+ term_width=64,
+ )
+ bar = progressbar.ProgressBar(max_value=3)
+ bar.label = 'build'
+ multibar['build'] = bar
+
+ bar.update(1, force=True)
+ multibar.render(force=True, flush=True)
+
+ output = fd.getvalue()
+ assert 'build' in output
+ assert '(1 of 3)' in output
+
+
+def test_multibar_constructor_wraps_external_bar_stream() -> None:
+ fd = io.StringIO()
+ bar = progressbar.ProgressBar(max_value=3)
+ multibar = progressbar.MultiBar(
+ [('build', bar)],
+ fd=fd,
+ initial_format=None,
+ finished_format=None,
+ remove_finished=None,
+ sort_reverse=False,
+ total=3,
+ term_width=64,
+ )
+
+ bar.update(1, force=True)
+ multibar.render(force=True, flush=True)
+
+ output = fd.getvalue()
+ assert 'build' in output
+ assert '(1 of 3)' in output
+
+
+def test_multibar_constructor_accepts_mapping() -> None:
+ fd = io.StringIO()
+ bar = progressbar.ProgressBar(max_value=3)
+ multibar = progressbar.MultiBar(
+ {'build': bar},
+ fd=fd,
+ initial_format=None,
+ finished_format=None,
+ remove_finished=None,
+ sort_reverse=False,
+ total=3,
+ term_width=64,
+ )
+
+ bar.update(1, force=True)
+ multibar.render(force=True, flush=True)
+
+ output = fd.getvalue()
+ assert 'build' in output
+ assert '(1 of 3)' in output
+
+
def test_multibar_finished_format() -> None:
multibar = progressbar.MultiBar(
finished_format='Finished {label}', show_finished=True
@@ -243,7 +407,173 @@ def test_multibar_threads() -> None:
time.sleep(0.1)
bar.update(3)
time.sleep(0.1)
- multibar.join()
+ # 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 eb79e66d..7accb586 100644
--- a/tests/test_progressbar.py
+++ b/tests/test_progressbar.py
@@ -1,11 +1,20 @@
import contextlib
+import gc
+import io
import os
+import signal
+import sys
import time
+from datetime import timedelta
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:
@@ -72,7 +81,290 @@ def test_dirty() -> None:
def test_negative_maximum() -> None:
- with pytest.raises(ValueError), progressbar.ProgressBar(
- max_value=-1
- ) as progress:
+ 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
index 3dd82d60..771fca41 100644
--- a/tests/test_progressbar_command.py
+++ b/tests/test_progressbar_command.py
@@ -2,6 +2,7 @@
import pytest
+import progressbar
import progressbar.__main__ as main
@@ -19,6 +20,39 @@ def test_size_to_bytes() -> None:
assert main.size_to_bytes('1024p') == 1152921504606846976
+def test_sleep_for_rate_limit_skips_when_unset(monkeypatch) -> None:
+ calls = []
+ monkeypatch.setattr(main.time, 'sleep', calls.append)
+ main._sleep_for_rate_limit(None, transferred=1024, started_at=0, now=1)
+ assert calls == []
+
+
+def test_sleep_for_rate_limit_sleeps_when_ahead(monkeypatch) -> None:
+ calls = []
+ monkeypatch.setattr(main.time, 'sleep', calls.append)
+ main._sleep_for_rate_limit(1024, transferred=2048, started_at=0, now=1)
+ assert calls == [1]
+
+
+def test_sleep_for_rate_limit_skips_when_on_schedule(monkeypatch) -> None:
+ calls = []
+ monkeypatch.setattr(main.time, 'sleep', calls.append)
+ main._sleep_for_rate_limit(1024, transferred=1024, started_at=0, now=1)
+ assert calls == []
+
+
+def test_main_passes_rate_limit(tmp_path, monkeypatch) -> None:
+ sleeps = []
+ monkeypatch.setattr(main.time, 'sleep', sleeps.append)
+ monkeypatch.setattr(main.time, 'monotonic', lambda: 0)
+ file = tmp_path / 'data.bin'
+ file.write_bytes(b'x' * 2048)
+ main.main(
+ ['--rate-limit', '1k', str(file), '-o', str(tmp_path / 'out.bin')],
+ )
+ assert sleeps
+
+
def test_filename_to_bytes(tmp_path) -> None:
file = tmp_path / 'test'
file.write_text('test')
@@ -142,3 +176,115 @@ def test_main_bytes_output(monkeypatch, tmp_path) -> None:
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
index 2881fac0..36ce4ad5 100644
--- a/tests/test_samples.py
+++ b/tests/test_samples.py
@@ -110,3 +110,20 @@ def test_timedelta_no_update() -> None:
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
index 4f53639e..928b9d0c 100644
--- a/tests/test_speed.py
+++ b/tests/test_speed.py
@@ -6,7 +6,9 @@
@pytest.mark.parametrize(
'total_seconds_elapsed,value,expected',
[
- (1, 0, ' 0.0 s/B'),
+ # 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'),
diff --git a/tests/test_stream.py b/tests/test_stream.py
index d14845d8..30bdaa9b 100644
--- a/tests/test_stream.py
+++ b/tests/test_stream.py
@@ -1,4 +1,5 @@
import io
+import logging
import os
import sys
@@ -8,10 +9,28 @@
from progressbar import terminal
+def reset_wrapped_streams() -> None:
+ while progressbar.streams.wrapped_logging:
+ progressbar.streams.unwrap_logging()
+ while (
+ progressbar.streams.wrapped_stdout
+ or progressbar.streams.wrapped_stderr
+ ):
+ progressbar.streams.unwrap(stderr=True, stdout=True)
+ progressbar.streams.wrapped_logging = 0
+ progressbar.streams.wrapped_stdout = 0
+ progressbar.streams.wrapped_stderr = 0
+ progressbar.streams.logging_handlers.clear()
+ for listener in list(progressbar.streams.listeners):
+ listener._finished = True
+ progressbar.streams.listeners.clear()
+ progressbar.streams.capturing = 0
+ progressbar.streams.update_capturing()
+
+
def test_nowrap() -> None:
# Make sure we definitely unwrap
- for _i in range(5):
- progressbar.streams.unwrap(stderr=True, stdout=True)
+ reset_wrapped_streams()
stdout = sys.stdout
stderr = sys.stderr
@@ -27,14 +46,12 @@ def test_nowrap() -> None:
assert stderr == sys.stderr
# Make sure we definitely unwrap
- for _i in range(5):
- progressbar.streams.unwrap(stderr=True, stdout=True)
+ reset_wrapped_streams()
def test_wrap() -> None:
# Make sure we definitely unwrap
- for _i in range(5):
- progressbar.streams.unwrap(stderr=True, stdout=True)
+ reset_wrapped_streams()
stdout = sys.stdout
stderr = sys.stderr
@@ -54,8 +71,295 @@ def test_wrap() -> None:
assert stderr == sys.stderr
# Make sure we definitely unwrap
- for _i in range(5):
- progressbar.streams.unwrap(stderr=True, stdout=True)
+ reset_wrapped_streams()
+
+
+def test_wrap_logging_retargets_existing_stderr_handler(monkeypatch) -> None:
+ reset_wrapped_streams()
+
+ stream = io.StringIO()
+ monkeypatch.setattr(progressbar.streams, 'original_stderr', stream)
+ monkeypatch.setattr(progressbar.streams, 'stderr', stream)
+ monkeypatch.setattr(sys, 'stderr', stream)
+
+ logger = logging.getLogger('progressbar-test-wrap-logging')
+ logger.handlers = []
+ logger.propagate = False
+ handler = logging.StreamHandler(sys.stderr)
+ logger.addHandler(handler)
+
+ progressbar.streams.wrap_stderr()
+ progressbar.streams.wrap_logging()
+ try:
+ assert handler.stream is progressbar.streams.stderr
+ finally:
+ progressbar.streams.unwrap_logging()
+ progressbar.streams.unwrap(stderr=True)
+ logger.handlers = []
+
+
+def test_unwrap_logging_restores_handler_stream(monkeypatch) -> None:
+ reset_wrapped_streams()
+
+ stream = io.StringIO()
+ monkeypatch.setattr(sys, 'stderr', stream)
+ monkeypatch.setattr(progressbar.streams, 'original_stderr', stream)
+ monkeypatch.setattr(progressbar.streams, 'stderr', stream)
+
+ logger = logging.getLogger('progressbar-test-unwrap-logging')
+ logger.handlers = []
+ logger.propagate = False
+ handler = logging.StreamHandler(sys.stderr)
+ logger.addHandler(handler)
+
+ progressbar.streams.wrap_stderr()
+ progressbar.streams.wrap_logging()
+ progressbar.streams.unwrap_logging()
+
+ try:
+ assert handler.stream is stream
+ finally:
+ progressbar.streams.unwrap(stderr=True)
+ logger.handlers = []
+
+
+def test_unwrap_logging_restores_handler_created_after_stderr_wrap(
+ monkeypatch,
+) -> None:
+ reset_wrapped_streams()
+
+ stream = io.StringIO()
+ monkeypatch.setattr(sys, 'stderr', stream)
+ monkeypatch.setattr(progressbar.streams, 'original_stderr', stream)
+ monkeypatch.setattr(progressbar.streams, 'stderr', stream)
+
+ logger = logging.getLogger('progressbar-test-wrapped-stderr-handler')
+ logger.handlers = []
+ logger.propagate = False
+
+ progressbar.streams.wrap_stderr()
+ wrapped_stderr = progressbar.streams.stderr
+ handler = logging.StreamHandler(sys.stderr)
+ logger.addHandler(handler)
+
+ try:
+ assert handler.stream is wrapped_stderr
+
+ progressbar.streams.wrap_logging()
+ progressbar.streams.unwrap_logging()
+ progressbar.streams.unwrap(stderr=True)
+
+ assert handler.stream is stream
+ finally:
+ progressbar.streams.unwrap_logging()
+ progressbar.streams.unwrap(stderr=True)
+ logger.handlers = []
+
+
+def test_wrap_logging_handles_nested_calls(monkeypatch) -> None:
+ reset_wrapped_streams()
+
+ stream = io.StringIO()
+ monkeypatch.setattr(sys, 'stderr', stream)
+ monkeypatch.setattr(progressbar.streams, 'original_stderr', stream)
+ monkeypatch.setattr(progressbar.streams, 'stderr', stream)
+
+ logger = logging.getLogger('progressbar-test-nested-wrap-logging')
+ logger.handlers = []
+ logger.propagate = False
+ handler = logging.StreamHandler(sys.stderr)
+ logger.addHandler(handler)
+
+ progressbar.streams.wrap_stderr()
+ progressbar.streams.wrap_logging()
+ progressbar.streams.wrap_logging()
+ try:
+ assert progressbar.streams.wrapped_logging == 2
+ progressbar.streams.unwrap_logging()
+ assert progressbar.streams.wrapped_logging == 1
+ finally:
+ progressbar.streams.unwrap_logging()
+ progressbar.streams.unwrap(stderr=True)
+ logger.handlers = []
+
+
+def test_wrap_logging_retargets_existing_stdout_handler(monkeypatch) -> None:
+ reset_wrapped_streams()
+
+ stream = io.StringIO()
+ monkeypatch.setattr(sys, 'stdout', stream)
+ monkeypatch.setattr(progressbar.streams, 'original_stdout', stream)
+ monkeypatch.setattr(progressbar.streams, 'stdout', stream)
+
+ logger = logging.getLogger('progressbar-test-wrap-stdout-logging')
+ logger.handlers = []
+ logger.propagate = False
+ handler = logging.StreamHandler(sys.stdout)
+ logger.addHandler(handler)
+
+ progressbar.streams.wrap_stdout()
+ progressbar.streams.wrap_logging()
+ try:
+ assert handler.stream is progressbar.streams.stdout
+ finally:
+ progressbar.streams.unwrap_logging()
+ progressbar.streams.unwrap(stdout=True)
+ logger.handlers = []
+
+
+def test_wrap_logging_ignores_handlers_that_reject_streams(
+ monkeypatch,
+) -> None:
+ class RejectingHandler(logging.StreamHandler):
+ def setStream(self, stream): # noqa: N802
+ raise ValueError('stream rejected')
+
+ reset_wrapped_streams()
+
+ stream = io.StringIO()
+ monkeypatch.setattr(sys, 'stderr', stream)
+ monkeypatch.setattr(progressbar.streams, 'original_stderr', stream)
+ monkeypatch.setattr(progressbar.streams, 'stderr', stream)
+
+ logger = logging.getLogger('progressbar-test-rejecting-handler')
+ logger.handlers = []
+ logger.propagate = False
+ handler = RejectingHandler(sys.stderr)
+ logger.addHandler(handler)
+
+ progressbar.streams.wrap_stderr()
+ try:
+ progressbar.streams.wrap_logging()
+ assert all(
+ logged_handler is not handler
+ for logged_handler, _stream in progressbar.streams.logging_handlers
+ )
+ finally:
+ progressbar.streams.unwrap_logging()
+ progressbar.streams.unwrap(stderr=True)
+ logger.handlers = []
+
+
+def test_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:
@@ -99,6 +403,19 @@ def test_no_newlines() -> None:
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:
@@ -148,3 +465,23 @@ def test_last_line_stream_methods() -> None:
# 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
index 3980e5f8..77815a41 100644
--- a/tests/test_terminal.py
+++ b/tests/test_terminal.py
@@ -1,3 +1,4 @@
+import io
import signal
import sys
import time
@@ -186,3 +187,38 @@ def test_base() -> None:
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
index ee19ab95..0eb17745 100644
--- a/tests/test_timed.py
+++ b/tests/test_timed.py
@@ -185,3 +185,26 @@ def gen():
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_unknown_length.py b/tests/test_unknown_length.py
index 65a54779..81a1c319 100644
--- a/tests/test_unknown_length.py
+++ b/tests/test_unknown_length.py
@@ -28,3 +28,23 @@ def test_unknown_length_at_start() -> None:
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
index e347acdb..375d9074 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,9 +1,12 @@
+import contextlib
import io
+import sys
import pytest
import progressbar
import progressbar.env
+from progressbar import utils
@pytest.mark.parametrize(
@@ -107,8 +110,153 @@ def test_is_ansi_terminal(monkeypatch) -> None:
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 RuntimeError('test')
+ 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
index 7ab3d88e..03eb3a9b 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -1,6 +1,8 @@
from __future__ import annotations
+import io
import time
+from datetime import timedelta
import pytest
@@ -14,13 +16,63 @@
def test_create_wrapper() -> None:
- with pytest.raises(AssertionError):
+ # 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: ',
@@ -63,6 +115,112 @@ def test_widgets_large_values(max_value) -> None:
p.finish()
+def test_postfix_widget_renders_mapping_sorted() -> None:
+ bar = progressbar.ProgressBar(
+ widgets=[progressbar.Postfix()],
+ variables={'postfix': {'z': 2, 'a': 1}},
+ fd=io.StringIO(),
+ term_width=80,
+ )
+ bar.start()
+ output = ''.join(bar._format_widgets())
+ assert output == ' a=1, z=2'
+
+
+def test_postfix_widget_renders_string() -> None:
+ bar = progressbar.ProgressBar(
+ widgets=[progressbar.Postfix()],
+ variables={'postfix': 'loss=0.25'},
+ fd=io.StringIO(),
+ term_width=80,
+ )
+ bar.start()
+ output = ''.join(bar._format_widgets())
+ assert output == ' loss=0.25'
+
+
+def test_postfix_widget_renders_other_values() -> None:
+ bar = progressbar.ProgressBar(
+ widgets=[progressbar.Postfix()],
+ variables={'postfix': 3},
+ fd=io.StringIO(),
+ term_width=80,
+ )
+ bar.start()
+ output = ''.join(bar._format_widgets())
+ assert output == ' 3'
+
+
+@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')
@@ -204,3 +362,67 @@ def test_all_widgets_max_width(max_width, term_width) -> None:
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
index 4c95fae4..5823f62a 100644
--- a/tests/test_windows.py
+++ b/tests/test_windows.py
@@ -88,3 +88,30 @@ def main() -> int:
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
index 3d2253f5..79307c91 100644
--- a/tests/test_with.py
+++ b/tests/test_with.py
@@ -1,3 +1,5 @@
+import io
+
import progressbar
@@ -17,3 +19,17 @@ 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/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 c6812323..12c7e62d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,15 +1,16 @@
[tox]
envlist =
- py38
- py39
py310
py311
py312
+ py313
+ py314
+ py315
docs
- black
ruff
-; mypy
-; codespell
+ 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]
@@ -18,7 +19,7 @@ deps =
pyright
commands =
pyright
- py.test --basetemp="{envtmpdir}" --confcutdir=.. {posargs}
+ 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
[testenv:mypy]
@@ -27,11 +28,6 @@ basepython = python3
deps = mypy
commands = mypy {toxinidir}/progressbar
-[testenv:black]
-basepython = python3
-deps = black
-commands = black --skip-string-normalization --line-length 79 {toxinidir}/progressbar
-
[testenv:docs]
changedir =
basepython = python3
@@ -39,10 +35,6 @@ deps = -r{toxinidir}/docs/requirements.txt
allowlist_externals =
rm
mkdir
-whitelist_externals =
- rm
- cd
- mkdir
commands =
rm -f docs/modules.rst
mkdir -p docs/_static
@@ -53,7 +45,7 @@ commands =
[testenv:ruff]
commands =
ruff check
- ruff format
+ ruff format --check
deps = ruff
skip_install = true
@@ -62,4 +54,3 @@ changedir = {toxinidir}
commands = codespell .
deps = codespell
skip_install = true
-command = codespell
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 00000000..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" },
+]