From 3dc8fa408df041a7cfc8647f62dc5f044f092c5e Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Tue, 15 Sep 2026 14:50:33 +0200 Subject: [PATCH 1/8] Keep the live console usable at phone widths and on macOS Two console problems surfaced after the homepage example switched to the default widget set and tutorial step 2 grew to 1000 items. At a 375px viewport the homepage terminal fitted 38 columns, and the default widgets need about 57 before the bar gets any room, so the bar rendered empty and the homepage console test timed out waiting for a coloured fill. The fit now shrinks the font instead of the column count below 60 columns, and restores the full size when the host grows back. Pyodide's own time.sleep costs about 80ms per 10ms call in Chromium on macOS, so the 1000-item run hit the console's 30 second stop at item 379. The worker installs a perf_counter based sleep, which keeps to the asked duration on every platform. A console test pins the per-call cost. --- docs/_static/livecode/livecode.js | 21 +++++++++++++++++++-- docs/_static/livecode/worker.js | 15 ++++++++++++++- tests/console/test_console.py | 31 +++++++++++++++++++++++++++++++ tests/console/test_homepage.py | 5 +++++ 4 files changed, 69 insertions(+), 3 deletions(-) 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/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; From 2e30059028a8df808fd22fe13e9d6a9c574f2cf9 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 20 Sep 2026 00:15:57 +0200 Subject: [PATCH 2/8] Apply automatic Ruff fixes to pull requests --- .github/workflows/autofix.yml | 36 +++++++++++++++++++++++++++++++++++ .github/workflows/main.yml | 6 ++++++ CONTRIBUTING.rst | 12 ++++++++++++ tox.ini | 11 +++++++++++ 4 files changed, 65 insertions(+) create mode 100644 .github/workflows/autofix.yml 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..a7227ab1 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -161,3 +161,15 @@ 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. +Push builds check the committed files without applying fixes. + +Apply the same fixes locally with:: + + uvx --with tox-uv tox -e ruff-fix diff --git a/tox.ini b/tox.ini index ae273629..8168f0d6 100644 --- a/tox.ini +++ b/tox.ini @@ -82,6 +82,7 @@ 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 From 0451a4203397bdcdc1b6ea27d8a1ad0077d8b95e Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 20 Sep 2026 03:20:45 +0200 Subject: [PATCH 3/8] Clarify autofix behaviour on branch pushes --- CONTRIBUTING.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index a7227ab1..d69e7299 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -168,7 +168,8 @@ 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. -Push builds check the committed files without applying fixes. +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:: From 3d0a93a7f75b918aa9bdf5218fd8325ba69d0da5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:14:49 +0200 Subject: [PATCH 4/8] build(deps): bump urllib3 from 2.7.0 to 2.8.0 (#329) * build(deps): bump urllib3 from 2.7.0 to 2.8.0 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.7.0 to 2.8.0. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.7.0...2.8.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.8.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] * Regenerate readme-unknown-length demo for python-utils 4.1.0 The docs-demos check renders every demo with the unpinned tests extra, which now resolves python-utils 4.1.0. That release shifts the bouncing marker of the unknown-length bar by one column, so the committed SVG no longer matched a fresh render. Regenerated with scripts/render_demos.py; the capture is byte-stable across runs on 4.1.0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0158vjQBvyviHd25pDwmdcJT * Ignore ICN001: ruff 0.17.1 now aliases datetime to dt The tox ruff env installs an unpinned ruff, and 0.17.1 adds a default import convention for datetime. The codebase imports it as `import datetime` throughout, so keep that style rather than rewriting every module in a dependency bump. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0158vjQBvyviHd25pDwmdcJT --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Co-authored-by: Claude Fable 5.1 --- docs/_static/demos/readme-unknown-length.svg | 2 +- ruff.toml | 1 + uv.lock | 10 +++++++--- 3 files changed, 9 insertions(+), 4 deletions(-) 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/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/uv.lock b/uv.lock index 11eed6ef..2e6e832f 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] From aee8555ba782d303d191e1cd2cd3d7c2c1b96ffa Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:48:38 -0400 Subject: [PATCH 5/8] Account for labels in the fast progress bar width --- .../performance-and-the-fast-path.rst | 5 ++ progressbar/fast.py | 4 +- tests/test_fast_default.py | 50 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) 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..4c737039 100644 --- a/progressbar/fast.py +++ b/progressbar/fast.py @@ -76,7 +76,9 @@ 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) + inner = max( + width - bar.custom_len(prefix + left + right + suffix) - 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/tests/test_fast_default.py b/tests/test_fast_default.py index b9fc8700..eaaa92f3 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,51 @@ 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 From 892eba0ee8ce94159d39fbeed73177fcdfe9d9c6 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 1 Nov 2026 04:48:41 +0100 Subject: [PATCH 6/8] fix: keep non-str labels and an uncallable custom_len rendering on the fast path after #331 4.6.0 rendered prefix and suffix through an f-string and never called custom_len on the fast path. #331 concatenates the labels and calls custom_len for the width, so an int label raised TypeError and custom_len=None or a float-returning custom_len raised. Stringify the labels, fall back to len_color for an uncallable custom_len and truncate its result to int. Tests pin the three cases. --- progressbar/fast.py | 18 +++++++++++++----- tests/test_fast_default.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/progressbar/fast.py b/progressbar/fast.py index 4c737039..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,9 +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 - bar.custom_len(prefix + left + right + suffix) - 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/tests/test_fast_default.py b/tests/test_fast_default.py index eaaa92f3..9a2b5f92 100644 --- a/tests/test_fast_default.py +++ b/tests/test_fast_default.py @@ -298,3 +298,36 @@ def test_shortcut_desc_shares_terminal_width(): ) == 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 From 05a60c9a2c8fe88570a4bed734579320f9a2cfe8 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sun, 1 Nov 2026 15:20:27 +0100 Subject: [PATCH 7/8] build: give every tox env an explicit changedir tox 4.67.5 rejects an empty changedir ("path value is empty"), which turned the docs, docs-demos, readme and console jobs red on develop and on #331. The five envs that left it empty now say {toxinidir}, which is what the empty value meant. --- tox.ini | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tox.ini b/tox.ini index 8168f0d6..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,7 +75,7 @@ commands = twine check --strict {envtmpdir}/dist/* [testenv:docs-linkcheck] -changedir = +changedir = {toxinidir} basepython = python3 deps = -r{toxinidir}/docs/requirements.txt commands = From 75d015c1311a15973453f359e86dec4a777bbf84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:29:08 +0000 Subject: [PATCH 8/8] build(deps): bump certifi (#332) Bumps the uv group with 1 update in the / directory: [certifi](https://github.com/certifi/python-certifi). Updates `certifi` from 2026.5.20 to 2026.10.18 - [Commits](https://github.com/certifi/python-certifi/compare/2026.05.20...2026.10.18) --- updated-dependencies: - dependency-name: certifi dependency-version: 2026.10.18 dependency-type: indirect dependency-group: uv ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 2e6e832f..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]]