diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml
new file mode 100644
index 00000000..7c4409f7
--- /dev/null
+++ b/.github/workflows/autofix.yml
@@ -0,0 +1,36 @@
+# Ruff rewrites quote styles and capitalises docstrings without any help
+# from a person, so a contributor should never have to. This hands those
+# fixes to the autofix.ci app, which commits them to the pull request
+# branch, forks included. The existing lint gate still reports any
+# findings that ruff cannot fix.
+#
+# The filename and the workflow name are both fixed by autofix.ci, which
+# uses them to identify the workflow it trusts. Renaming either one stops
+# the app from accepting the patch.
+name: autofix.ci # needed to securely identify the workflow
+
+on:
+ pull_request:
+ push:
+ branches: [develop, master]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ autofix:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ with:
+ python-version: '3.13'
+ - run: uvx --with tox-uv tox -e ruff-fix
+ - uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 71cbdf9c..10025e64 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -89,7 +89,13 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip tox
+ - name: Apply Ruff fixes to pull requests
+ if: github.event_name == 'pull_request' && matrix.tox-env == 'ruff'
+ run: tox -e ruff-fix
- name: Test with tox
+ env:
+ RUFF_OUTPUT_FORMAT: >-
+ ${{ github.event_name == 'pull_request' && 'github' || 'full' }}
# Step-level continue-on-error keeps the job green for
# experimental (pre-release Python) environments while still
# showing the failing step in the logs
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 5477d533..d69e7299 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -161,3 +161,16 @@ To run a subset of tests::
$ uv run pytest tests/some_test.py
.. _git-flow-avh: https://github.com/petervanderdoes/gitflow
+
+Automatic formatting
+--------------------
+
+Pull requests apply Ruff fixes before the lint check. With the autofix.ci
+app installed for this repository, those fixes are committed to the pull
+request branch, including forks. Findings Ruff cannot fix still fail CI.
+The CI lint job checks committed files on pushes. The separate autofix.ci
+workflow also runs on pushes to develop and master and can commit fixes.
+
+Apply the same fixes locally with::
+
+ uvx --with tox-uv tox -e ruff-fix
diff --git a/docs/_static/demos/readme-unknown-length.svg b/docs/_static/demos/readme-unknown-length.svg
index 1566c076..da58cde1 100644
--- a/docs/_static/demos/readme-unknown-length.svg
+++ b/docs/_static/demos/readme-unknown-length.svg
@@ -37,5 +37,5 @@
Unknown length
- / |# | 0 Elapsed Time: 0:00:00- |# | 10 Elapsed Time: 0:00:00\ | # | 20 Elapsed Time: 0:00:00| | # | 30 Elapsed Time: 0:00:00/ | # | 40 Elapsed Time: 0:00:00- | # | 50 Elapsed Time: 0:00:00\ | # | 60 Elapsed Time: 0:00:00| | # | 70 Elapsed Time: 0:00:00/ | # | 80 Elapsed Time: 0:00:00- | # | 90 Elapsed Time: 0:00:00\ | # | 100 Elapsed Time: 0:00:01| | # | 110 Elapsed Time: 0:00:01| | # | 110 Elapsed Time: 0:00:01
+ / |# | 0 Elapsed Time: 0:00:00- | # | 10 Elapsed Time: 0:00:00\ | # | 20 Elapsed Time: 0:00:00| | # | 30 Elapsed Time: 0:00:00/ | # | 40 Elapsed Time: 0:00:00- | # | 50 Elapsed Time: 0:00:00\ | # | 60 Elapsed Time: 0:00:00| | # | 70 Elapsed Time: 0:00:00/ | # | 80 Elapsed Time: 0:00:00- | # | 90 Elapsed Time: 0:00:00\ | # | 100 Elapsed Time: 0:00:01| | # | 110 Elapsed Time: 0:00:01| | # | 110 Elapsed Time: 0:00:01
diff --git a/docs/_static/livecode/livecode.js b/docs/_static/livecode/livecode.js
index 03895778..ed904972 100644
--- a/docs/_static/livecode/livecode.js
+++ b/docs/_static/livecode/livecode.js
@@ -4,6 +4,12 @@
// click, so a reader who never runs anything never downloads Pyodide.
const COLUMNS = 80;
+// Narrowest terminal the fit may produce. progressbar's default widget set
+// needs about 57 columns before the bar itself gets any room, so a phone
+// width host shrinks the font instead of the column count.
+const MIN_COLUMNS = 60;
+const FONT_SIZE = 13;
+const MIN_FONT_SIZE = 6;
const RUN_TIMEOUT_MS = 30000;
// Read the Docs serves the site under `/en//`, so root-absolute
@@ -133,8 +139,19 @@ function fitTerminalWidth(terminal, host) {
terminal.loadAddon(addon);
/** @type {() => void} */
const fit = () => {
+ // Measure at the full font first so a host that grows back gets its
+ // original size, then shrink the font until MIN_COLUMNS fit.
+ terminal.options.fontSize = FONT_SIZE;
/** @type {{cols: number, rows: number} | undefined} */
- const dimensions = addon.proposeDimensions();
+ let dimensions = addon.proposeDimensions();
+ if (dimensions && Number.isInteger(dimensions.cols)
+ && dimensions.cols > 0 && dimensions.cols < MIN_COLUMNS) {
+ terminal.options.fontSize = Math.max(
+ MIN_FONT_SIZE,
+ Math.floor(FONT_SIZE * dimensions.cols / MIN_COLUMNS),
+ );
+ dimensions = addon.proposeDimensions();
+ }
if (dimensions && Number.isInteger(dimensions.cols)
&& dimensions.cols > 0 && dimensions.cols !== terminal.cols) {
terminal.resize(dimensions.cols, terminal.rows);
@@ -181,7 +198,7 @@ function createPanel(container, source) {
cols: COLUMNS,
rows: container.closest('.home-quickstart') ? 3 : 12,
convertEol: true,
- fontSize: 13,
+ fontSize: FONT_SIZE,
theme: {background: '#101418', foreground: '#d6e2ef'},
});
terminal.open(terminalHost);
diff --git a/docs/_static/livecode/worker.js b/docs/_static/livecode/worker.js
index 624ce06b..1c2ec39c 100644
--- a/docs/_static/livecode/worker.js
+++ b/docs/_static/livecode/worker.js
@@ -37,7 +37,7 @@ const THREAD_ERROR_MESSAGE =
'animation above is the real output. See the source for the pattern.';
const BRIDGE = `
-import os, sys, js, traceback
+import os, sys, time, js, traceback
from pyodide.ffi import to_js
@@ -109,9 +109,22 @@ def _excepthook(exc_type, exc_value, exc_tb):
traceback.print_exception(exc_type, exc_value, exc_tb, file=sys.stderr)
+def _sleep(seconds):
+ # Pyodide's own time.sleep costs about 80ms per 10ms call in Chromium
+ # on macOS (measured 2026-09-15; Linux stays close to nominal), which
+ # pushed a 1000-item tutorial run past the console's 30 second stop.
+ # A perf_counter loop keeps to the asked duration on every platform.
+ # It burns the worker's CPU while waiting, which is the same thread
+ # the blocking sleep already held.
+ deadline = time.perf_counter() + max(0.0, seconds)
+ while time.perf_counter() < deadline:
+ pass
+
+
def _install_bridge(columns):
global _LAST_ERROR
_LAST_ERROR = None
+ time.sleep = _sleep
os.environ['TERM'] = 'xterm-256color'
os.environ['COLORTERM'] = 'truecolor'
os.environ['COLUMNS'] = str(columns)
diff --git a/docs/explanation/performance-and-the-fast-path.rst b/docs/explanation/performance-and-the-fast-path.rst
index 100e502e..d21b4626 100644
--- a/docs/explanation/performance-and-the-fast-path.rst
+++ b/docs/explanation/performance-and-the-fast-path.rst
@@ -42,6 +42,11 @@ gets the full ``ProgressBar`` instead. You can also construct
renderer without going through the shortcut -- see
:doc:`../reference/progressbar`.
+The known-length fast bar reserves width for ``prefix`` (or ``desc``)
+and ``suffix`` before sizing its bar. It uses ``custom_len`` to measure
+the fixed text, so ANSI colors and custom display-width rules do not
+consume extra bar columns.
+
This dispatch only happens inside ``progressbar.progressbar()``.
Constructing ``ProgressBar()`` yourself always gets the full class.
diff --git a/progressbar/fast.py b/progressbar/fast.py
index 3fd71d62..b3d2fd7b 100644
--- a/progressbar/fast.py
+++ b/progressbar/fast.py
@@ -16,6 +16,7 @@
from . import (
bar as bar_module,
base,
+ utils,
)
#: Optional native line-formatter hook. Left as ``None`` so the pure-Python
@@ -58,8 +59,10 @@ def _pure_format_fast_line(bar: FastProgressBar) -> str:
width = bar.term_width
elapsed = bar._fast_elapsed()
elapsed_text = _format_seconds(elapsed)
- prefix = bar.prefix or ''
- suffix = bar.suffix or ''
+ # 4.6.0 rendered the labels through an f-string, so a non-str label such
+ # as an int still printed. Keep that: str() before the width arithmetic.
+ prefix = str(bar.prefix or '')
+ suffix = str(bar.suffix or '')
known = max_value not in (None, base.UnknownLength)
if known:
@@ -76,7 +79,14 @@ def _pure_format_fast_line(bar: FastProgressBar) -> str:
eta = '--:--:--'
left = f'{pct:3.0f}% {count} '
right = f' Elapsed Time: {elapsed_text} ETA: {eta}'
- inner = max(width - len(left) - len(right) - 2, 0)
+ # 4.6.0 never called `custom_len` on this path, so a `None` or a
+ # float-returning measurer rendered fine. Fall back to the default
+ # measurer for an uncallable one and truncate the result to int.
+ measure: Callable[[str], int] | None = getattr(bar, 'custom_len', None)
+ if not callable(measure):
+ measure = utils.len_color
+ fixed = int(measure(prefix + left + right + suffix))
+ inner = max(width - fixed - 2, 0)
filled = int(inner * done / total) if total else inner
barstr = '|' + '#' * filled + ' ' * (inner - filled) + '|'
return f'{prefix}{left}{barstr}{right}{suffix}'
diff --git a/ruff.toml b/ruff.toml
index 7a95ec7d..6b729e6b 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -39,6 +39,7 @@ ignore = [
'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
+ 'ICN001', # ruff 0.17.1 wants `import datetime as dt`; the codebase uses `datetime.`
]
select = [
diff --git a/tests/console/test_console.py b/tests/console/test_console.py
index cfeb2a74..16815967 100644
--- a/tests/console/test_console.py
+++ b/tests/console/test_console.py
@@ -357,3 +357,34 @@ def fail_wheels_manifest(route: Route) -> None:
# and not a hang. `resetWorker()` nulling out the cached `booting`
# promise is what makes this 2 instead of 1.
assert _worker_count(browser_page) == 2
+
+
+def test_sleep_in_the_console_keeps_to_its_duration(
+ server: str,
+ page: tuple[Page, list[str]],
+) -> None:
+ """Pyodide's own ``time.sleep`` costs about 80ms per 10ms call in
+ Chromium on macOS, which pushed a 1000-item tutorial run past the
+ console's 30s stop. The worker installs its own sleep, so a call
+ must cost close to what it asks for on every platform.
+ """
+ browser_page, errors = page
+ browser_page.goto(f'{server}/tutorial/step2.html')
+ browser_page.get_by_role('button', name='Edit code').click()
+ browser_page.locator('.demo-editor').fill(
+ 'import time\n'
+ 'started = time.perf_counter()\n'
+ 'for _ in range(20):\n'
+ ' time.sleep(0.01)\n'
+ 'per_call_ms = (time.perf_counter() - started) / 20 * 1000\n'
+ "print(f'per call: {per_call_ms:.1f} ms')\n"
+ )
+ browser_page.click('.demo-button')
+ _wait_for_terminal_text(browser_page, 'per call:', BOOT_TIMEOUT_MS)
+ text: str = browser_page.evaluate(
+ 'window.__consoleTestTerminal.buffer.active.getLine(0)'
+ '.translateToString(true)'
+ )
+ per_call_ms: float = float(text.split('per call:')[1].split('ms')[0])
+ assert 8 <= per_call_ms <= 25, text
+ assert not errors
diff --git a/tests/console/test_homepage.py b/tests/console/test_homepage.py
index 5a49a236..76e9e902 100644
--- a/tests/console/test_homepage.py
+++ b/tests/console/test_homepage.py
@@ -119,6 +119,11 @@ def test_homepage_output_has_colour_and_fits_after_resizing(
for width in (1440, 768, 375):
browser_page.set_viewport_size({'width': width, 'height': 1000})
browser_page.get_by_role('button', name='Run', exact=True).click()
+ # The default widget set needs about 57 columns before the bar
+ # gets any room, so the console must never fit narrower than that.
+ browser_page.wait_for_function(
+ '() => window.__consoleTestTerminal.cols >= 60', timeout=5000
+ )
_wait_for_terminal_text(browser_page, '100%', BOOT_TIMEOUT_MS)
browser_page.wait_for_function("""() => {
const term = window.__consoleTestTerminal;
diff --git a/tests/test_fast_default.py b/tests/test_fast_default.py
index b9fc8700..9a2b5f92 100644
--- a/tests/test_fast_default.py
+++ b/tests/test_fast_default.py
@@ -4,6 +4,8 @@
import io
import sys
+import pytest
+
import progressbar
# Alias (not a `from` import) so CodeQL doesn't flag `progressbar` as imported
@@ -248,3 +250,84 @@ def test_fast_path_does_not_import_widgets_or_colors():
)
assert out.returncode == 0, out.stderr
assert 'ok' in out.stdout
+
+
+@pytest.mark.parametrize('width', [80, 110])
+@pytest.mark.parametrize(
+ ('prefix', 'suffix'),
+ [
+ ('', ''),
+ ('load ', ''),
+ ('', ' done'),
+ ('load ', ' done'),
+ ('\x1b[31mload \x1b[0m', ' done'),
+ ],
+)
+def test_fast_labels_share_terminal_width(width, prefix, suffix):
+ fd = TTY()
+ bar = fast_module.FastProgressBar(
+ max_value=10, fd=fd, term_width=width, prefix=prefix, suffix=suffix
+ )
+ assert list(bar(range(10))) == list(range(10))
+ for frame in fd.repaints():
+ assert progressbar.utils.len_color(frame.rstrip('\r\n')) == width
+
+
+def test_fast_labels_use_custom_width_measurement():
+ def display_width(text):
+ return progressbar.utils.len_color(text.replace('界', 'xx'))
+
+ fd = TTY()
+ bar = fast_module.FastProgressBar(
+ max_value=10,
+ fd=fd,
+ term_width=80,
+ prefix='界 ',
+ suffix=' 界',
+ custom_len=display_width,
+ )
+ assert list(bar(range(10))) == list(range(10))
+ for frame in fd.repaints():
+ assert display_width(frame.rstrip('\r\n')) == 80
+
+
+def test_shortcut_desc_shares_terminal_width():
+ fd = TTY()
+ assert list(
+ progressbar.progressbar(range(10), fd=fd, desc='load ', term_width=80)
+ ) == list(range(10))
+ for frame in fd.repaints():
+ assert progressbar.utils.len_color(frame.rstrip('\r\n')) == 80
+
+
+@pytest.mark.parametrize(
+ ('label', 'rendered'),
+ [(1, '1'), (b'x ', 'b\x27x \x27'), (True, 'True')],
+)
+def test_fast_non_str_labels_still_render(label, rendered):
+ # 4.6.0 rendered labels through an f-string, so non-str values printed.
+ fd = TTY()
+ bar = fast_module.FastProgressBar(
+ max_value=3, fd=fd, term_width=80, prefix=label, suffix=label
+ )
+ assert list(bar(range(3))) == [0, 1, 2]
+ frame = fd.repaints()[-1].rstrip('\r\n')
+ assert frame.startswith(rendered)
+ assert frame.endswith(rendered)
+ assert len(frame) == 80
+
+
+@pytest.mark.parametrize('custom_len', [None, lambda text: float(len(text))])
+def test_fast_tolerates_uncallable_or_float_custom_len(custom_len):
+ # 4.6.0 never called custom_len on the fast path, so these rendered.
+ fd = TTY()
+ bar = fast_module.FastProgressBar(
+ max_value=3,
+ fd=fd,
+ term_width=80,
+ prefix='load ',
+ custom_len=custom_len,
+ )
+ assert list(bar(range(3))) == [0, 1, 2]
+ for frame in fd.repaints():
+ assert len(frame.rstrip('\r\n')) == 80
diff --git a/tox.ini b/tox.ini
index ae273629..16eadf33 100644
--- a/tox.ini
+++ b/tox.ini
@@ -26,13 +26,13 @@ commands =
skip_install = true
[testenv:mypy]
-changedir =
+changedir = {toxinidir}
basepython = python3
deps = mypy
commands = mypy {toxinidir}/progressbar
[testenv:docs]
-changedir =
+changedir = {toxinidir}
basepython = python3
deps =
-r{toxinidir}/docs/requirements.txt
@@ -49,7 +49,7 @@ commands =
sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html {posargs}
[testenv:docs-demos]
-changedir =
+changedir = {toxinidir}
basepython = python3
deps = -r{toxinidir}/tests/requirements.txt
commands =
@@ -60,7 +60,7 @@ commands =
; readme_renderer check over it, so a README change that PyPI would
; refuse or silently strip fails CI instead of shipping a broken page.
[testenv:readme]
-changedir =
+changedir = {toxinidir}
basepython = python3
skip_install = true
deps =
@@ -75,13 +75,14 @@ commands =
twine check --strict {envtmpdir}/dist/*
[testenv:docs-linkcheck]
-changedir =
+changedir = {toxinidir}
basepython = python3
deps = -r{toxinidir}/docs/requirements.txt
commands =
sphinx-build -b linkcheck -d docs/_build/doctrees docs docs/_build/linkcheck
[testenv:ruff]
+pass_env = RUFF_OUTPUT_FORMAT
commands =
ruff check
ruff format --check
@@ -93,3 +94,13 @@ changedir = {toxinidir}
commands = codespell .
deps = codespell
skip_install = true
+
+
+[testenv:ruff-fix]
+labels = fix
+description = Apply the available Ruff fixes
+commands =
+ ruff check --fix --exit-zero
+ ruff format
+deps = ruff
+skip_install = true
diff --git a/uv.lock b/uv.lock
index 11eed6ef..31074928 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 5
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.15'",
@@ -28,11 +28,11 @@ wheels = [
[[package]]
name = "certifi"
-version = "2026.5.20"
+version = "2026.10.18"
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" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz", hash = "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e", size = 135227, upload-time = "2026-10-18T16:21:07.655Z" }
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" },
+ { url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl", hash = "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", size = 134093, upload-time = "2026-10-18T16:21:06.276Z" },
]
[[package]]
@@ -796,6 +796,9 @@ docs-tests = [
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-cov" },
+ { 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'" },
]
fast = [
{ name = "speedups" },
@@ -827,6 +830,7 @@ requires-dist = [
{ 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", marker = "extra == 'docs-tests'", specifier = ">=1.8.5" },
{ name = "sphinx-autodoc-typehints", marker = "extra == 'docs'", specifier = ">=1.6.0" },
]
provides-extras = ["fast", "docs", "tests", "docs-tests"]
@@ -1268,9 +1272,9 @@ wheels = [
[[package]]
name = "urllib3"
-version = "2.7.0"
+version = "2.8.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" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
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" },
+ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]