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" }, ]