From b635c1bf84946930fec9e12476876418e111c704 Mon Sep 17 00:00:00 2001 From: Alberto-Codes Date: Fri, 8 May 2026 14:06:02 -0700 Subject: [PATCH 1/2] fix(cli): avoid click<8.2 incompatibility in progressbar hidden kwarg Replace direct typer.progressbar(hidden=...) calls with a _maybe_progressbar helper that skips the progress bar entirely when hidden. The hidden kwarg was added in click 8.2.0 but typer allows click>=8.0.0, causing TypeError for users with older click versions. Closes #404 --- src/docvet/cli/_runners.py | 78 ++++++++++++----- tests/unit/test_cli_progress.py | 145 +++++++++++++++++--------------- 2 files changed, 131 insertions(+), 92 deletions(-) diff --git a/src/docvet/cli/_runners.py b/src/docvet/cli/_runners.py index 7bf4500..50efc70 100644 --- a/src/docvet/cli/_runners.py +++ b/src/docvet/cli/_runners.py @@ -4,7 +4,9 @@ module, and returns findings. The ``_run_fix`` runner additionally writes scaffolded sections back to files (or collects diffs in dry-run mode). Git helpers (``_get_git_diff``, ``_get_git_blame``) provide -raw VCS data for the freshness runner. +raw VCS data for the freshness runner. Progress display is handled +by ``_maybe_progressbar``, which avoids the ``click >= 8.2`` +``hidden`` kwarg requirement. See Also: [`docvet.cli`][]: CLI application and subcommands. @@ -22,7 +24,10 @@ import importlib.util import sys +from collections.abc import Iterator, Sequence +from contextlib import contextmanager from pathlib import Path +from typing import TypeVar import typer @@ -33,6 +38,35 @@ from . import DiscoveryMode, FreshnessMode +V = TypeVar("V") + + +@contextmanager +def _maybe_progressbar( + items: Sequence[V], + *, + label: str, + show: bool, +) -> Iterator[Iterator[V]]: + """Wrap items in a typer progress bar, or iterate directly. + + Avoids passing ``hidden`` to ``typer.progressbar`` which relies + on a ``click >= 8.2`` parameter not available in all environments. + + Args: + items: Items to iterate over. + label: Progress bar label. + show: When ``True``, display a progress bar on stderr. + + Yields: + An iterator over *items*. + """ + if show: + with typer.progressbar(items, label=label, file=sys.stderr) as progress: + yield progress + else: + yield iter(items) + def _get_git_diff( file_path: Path, @@ -138,12 +172,14 @@ def _run_enrichment( Reads each file, parses its AST, and runs all enabled enrichment rules. Passes ``config.docstring_style`` to the enrichment checker for style-aware section detection and rule gating. Files that fail - to parse are skipped with a warning. + to parse are skipped with a warning. Uses ``_maybe_progressbar`` + for click-compatible progress display. Args: files: Discovered Python file paths. config: Loaded docvet configuration. - show_progress: Display a progress bar on stderr. + show_progress: Display a progress bar on stderr via + ``_maybe_progressbar``. Returns: A tuple of ``(findings, symbol_count)`` where *symbol_count* @@ -151,9 +187,7 @@ def _run_enrichment( """ all_findings: list[Finding] = [] symbol_count = 0 - with typer.progressbar( - files, label="enrichment", file=sys.stderr, hidden=not show_progress - ) as progress: + with _maybe_progressbar(files, label="enrichment", show=show_progress) as progress: for file_path in progress: source = file_path.read_text(encoding="utf-8") try: @@ -184,11 +218,13 @@ def _run_presence( Reads each file, parses its AST, and checks for missing docstrings. Files that fail to parse are skipped with a warning. Aggregates per-file coverage statistics into a single :class:`PresenceStats`. + Uses ``_maybe_progressbar`` for click-compatible progress display. Args: files: Discovered Python file paths. config: Loaded docvet configuration. - show_progress: Display a progress bar on stderr. + show_progress: Display a progress bar on stderr via + ``_maybe_progressbar``. Returns: A tuple of ``(findings, stats)`` where *findings* is a list of @@ -198,9 +234,7 @@ def _run_presence( all_findings: list[Finding] = [] total_documented = 0 total_total = 0 - with typer.progressbar( - files, label="presence", file=sys.stderr, hidden=not show_progress - ) as progress: + with _maybe_progressbar(files, label="presence", show=show_progress) as progress: for file_path in progress: source = file_path.read_text(encoding="utf-8") try: @@ -230,14 +264,16 @@ def _run_freshness( For diff mode, reads each file, parses the AST, obtains its git diff, and calls ``check_freshness_diff``. For drift mode, reads each file, parses the AST, runs ``git blame --line-porcelain``, - and calls ``check_freshness_drift``. + and calls ``check_freshness_drift``. Uses ``_maybe_progressbar`` + for click-compatible progress display. Args: files: Discovered Python file paths. config: Loaded docvet configuration. freshness_mode: The freshness check strategy (diff or drift). discovery_mode: Controls which git diff variant to run. - show_progress: Display a progress bar on stderr. + show_progress: Display a progress bar on stderr via + ``_maybe_progressbar``. Returns: A tuple of ``(findings, symbol_count)`` where *symbol_count* @@ -246,8 +282,8 @@ def _run_freshness( if freshness_mode is not FreshnessMode.DIFF: all_findings: list[Finding] = [] symbol_count = 0 - with typer.progressbar( - files, label="freshness", file=sys.stderr, hidden=not show_progress + with _maybe_progressbar( + files, label="freshness", show=show_progress ) as progress: for file_path in progress: source = file_path.read_text(encoding="utf-8") @@ -268,9 +304,7 @@ def _run_freshness( all_findings: list[Finding] = [] symbol_count = 0 - with typer.progressbar( - files, label="freshness", file=sys.stderr, hidden=not show_progress - ) as progress: + with _maybe_progressbar(files, label="freshness", show=show_progress) as progress: for file_path in progress: source = file_path.read_text(encoding="utf-8") try: @@ -358,14 +392,16 @@ def _run_fix( them via ``scaffold_missing_sections``, and either writes the result or collects diffs. In write mode, re-runs enrichment to collect scaffold-incomplete findings. In dry-run mode, collects diffs - without writing or re-checking. + without writing or re-checking. Uses ``_maybe_progressbar`` for + click-compatible progress display. Args: files: Discovered Python file paths. config: Loaded docvet configuration. dry_run: When ``True``, collect diffs without writing files or re-running enrichment. - show_progress: Display a progress bar on stderr. + show_progress: Display a progress bar on stderr via + ``_maybe_progressbar``. Returns: A tuple of ``(scaffold_findings, files_modified, sections_scaffolded, @@ -380,9 +416,7 @@ def _run_fix( sections_scaffolded = 0 diffs: list[tuple[str, str, str]] = [] - with typer.progressbar( - files, label="fix", file=sys.stderr, hidden=not show_progress - ) as progress: + with _maybe_progressbar(files, label="fix", show=show_progress) as progress: for file_path in progress: source = file_path.read_text(encoding="utf-8") try: diff --git a/tests/unit/test_cli_progress.py b/tests/unit/test_cli_progress.py index f2e3fb5..7c0f28d 100644 --- a/tests/unit/test_cli_progress.py +++ b/tests/unit/test_cli_progress.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +from contextlib import contextmanager from unittest.mock import MagicMock import pytest @@ -33,78 +34,63 @@ def config(tmp_path): @pytest.fixture -def mock_progressbar(mocker): - """Mock typer.progressbar as a context manager yielding the input iterable.""" - mock_pb = mocker.patch("docvet.cli.typer.progressbar") +def mock_maybe_progressbar(mocker): + """Mock _maybe_progressbar as a context manager yielding the input iterable.""" + mock_pb = mocker.patch("docvet.cli._runners._maybe_progressbar") - # Store original iterable so context manager yields the actual files - def make_ctx(*args, **kwargs): - ctx = MagicMock() - ctx.__enter__ = MagicMock(return_value=args[0] if args else []) - ctx.__exit__ = MagicMock(return_value=False) - return ctx + @contextmanager + def make_ctx(items, *, label, show): + yield iter(items) mock_pb.side_effect = make_ctx return mock_pb # --------------------------------------------------------------------------- -# Task 1 + Task 4.1/4.2: _run_enrichment progress bar +# _run_enrichment progress bar # --------------------------------------------------------------------------- class TestRunEnrichmentProgressBar: - def test_enrichment_progressbar_hidden_false_when_show_progress_true( - self, mocker, mock_progressbar, simple_py_file, config + def test_enrichment_progressbar_show_true( + self, mocker, mock_maybe_progressbar, simple_py_file, config ): mocker.patch("docvet.cli.check_enrichment", return_value=[]) _run_enrichment([simple_py_file], config, show_progress=True) - mock_progressbar.assert_called_once_with( + mock_maybe_progressbar.assert_called_once_with( [simple_py_file], label="enrichment", - file=sys.stderr, - hidden=False, + show=True, ) - def test_enrichment_progressbar_hidden_true_when_show_progress_false( - self, mocker, mock_progressbar, simple_py_file, config + def test_enrichment_progressbar_show_false( + self, mocker, mock_maybe_progressbar, simple_py_file, config ): mocker.patch("docvet.cli.check_enrichment", return_value=[]) _run_enrichment([simple_py_file], config, show_progress=False) - mock_progressbar.assert_called_once_with( + mock_maybe_progressbar.assert_called_once_with( [simple_py_file], label="enrichment", - file=sys.stderr, - hidden=True, + show=False, ) def test_enrichment_progressbar_default_show_progress_is_false( - self, mocker, mock_progressbar, simple_py_file, config + self, mocker, mock_maybe_progressbar, simple_py_file, config ): mocker.patch("docvet.cli.check_enrichment", return_value=[]) _run_enrichment([simple_py_file], config) - mock_progressbar.assert_called_once_with( + mock_maybe_progressbar.assert_called_once_with( [simple_py_file], label="enrichment", - file=sys.stderr, - hidden=True, + show=False, ) - def test_enrichment_progressbar_writes_to_stderr_not_stdout( - self, mocker, mock_progressbar, simple_py_file, config - ): - mocker.patch("docvet.cli.check_enrichment", return_value=[]) - - _run_enrichment([simple_py_file], config, show_progress=True) - - assert mock_progressbar.call_args.kwargs["file"] is sys.stderr - def test_enrichment_findings_identical_with_and_without_progress( self, mocker, simple_py_file, config ): @@ -133,13 +119,13 @@ def test_enrichment_empty_files_returns_empty_list(self, config): # --------------------------------------------------------------------------- -# Task 2 + Task 4.3: _run_freshness progress bar +# _run_freshness progress bar # --------------------------------------------------------------------------- class TestRunFreshnessProgressBar: - def test_freshness_diff_progressbar_hidden_false_when_show_progress_true( - self, mocker, mock_progressbar, simple_py_file, config + def test_freshness_diff_progressbar_show_true( + self, mocker, mock_maybe_progressbar, simple_py_file, config ): mocker.patch("docvet.cli.check_freshness_diff", return_value=[]) mocker.patch("docvet.cli._get_git_diff", return_value="") @@ -152,15 +138,14 @@ def test_freshness_diff_progressbar_hidden_false_when_show_progress_true( show_progress=True, ) - mock_progressbar.assert_called_once_with( + mock_maybe_progressbar.assert_called_once_with( [simple_py_file], label="freshness", - file=sys.stderr, - hidden=False, + show=True, ) - def test_freshness_diff_progressbar_hidden_true_when_show_progress_false( - self, mocker, mock_progressbar, simple_py_file, config + def test_freshness_diff_progressbar_show_false( + self, mocker, mock_maybe_progressbar, simple_py_file, config ): mocker.patch("docvet.cli.check_freshness_diff", return_value=[]) mocker.patch("docvet.cli._get_git_diff", return_value="") @@ -173,15 +158,14 @@ def test_freshness_diff_progressbar_hidden_true_when_show_progress_false( show_progress=False, ) - mock_progressbar.assert_called_once_with( + mock_maybe_progressbar.assert_called_once_with( [simple_py_file], label="freshness", - file=sys.stderr, - hidden=True, + show=False, ) - def test_freshness_drift_progressbar_hidden_false_when_show_progress_true( - self, mocker, mock_progressbar, simple_py_file, config + def test_freshness_drift_progressbar_show_true( + self, mocker, mock_maybe_progressbar, simple_py_file, config ): mocker.patch("docvet.cli.check_freshness_drift", return_value=[]) mocker.patch("docvet.cli._get_git_blame", return_value="") @@ -194,15 +178,14 @@ def test_freshness_drift_progressbar_hidden_false_when_show_progress_true( show_progress=True, ) - mock_progressbar.assert_called_once_with( + mock_maybe_progressbar.assert_called_once_with( [simple_py_file], label="freshness", - file=sys.stderr, - hidden=False, + show=True, ) - def test_freshness_drift_progressbar_hidden_true_when_show_progress_false( - self, mocker, mock_progressbar, simple_py_file, config + def test_freshness_drift_progressbar_show_false( + self, mocker, mock_maybe_progressbar, simple_py_file, config ): mocker.patch("docvet.cli.check_freshness_drift", return_value=[]) mocker.patch("docvet.cli._get_git_blame", return_value="") @@ -215,29 +198,12 @@ def test_freshness_drift_progressbar_hidden_true_when_show_progress_false( show_progress=False, ) - mock_progressbar.assert_called_once_with( + mock_maybe_progressbar.assert_called_once_with( [simple_py_file], label="freshness", - file=sys.stderr, - hidden=True, - ) - - def test_freshness_progressbar_writes_to_stderr_not_stdout( - self, mocker, mock_progressbar, simple_py_file, config - ): - mocker.patch("docvet.cli.check_freshness_diff", return_value=[]) - mocker.patch("docvet.cli._get_git_diff", return_value="") - - _run_freshness( - [simple_py_file], - config, - freshness_mode=FreshnessMode.DIFF, - discovery_mode=DiscoveryMode.DIFF, - show_progress=True, + show=False, ) - assert mock_progressbar.call_args.kwargs["file"] is sys.stderr - def test_freshness_findings_identical_with_and_without_progress( self, mocker, simple_py_file, config ): @@ -306,3 +272,42 @@ def test_freshness_empty_files_returns_empty_list(self, config): findings, count = _run_freshness([], config, show_progress=True) assert findings == [] assert count == 0 + + +# --------------------------------------------------------------------------- +# _maybe_progressbar unit tests +# --------------------------------------------------------------------------- + + +class TestMaybeProgressbar: + def test_show_false_yields_plain_iterator(self): + from docvet.cli._runners import _maybe_progressbar + + items = [1, 2, 3] + with _maybe_progressbar(items, label="test", show=False) as it: + assert list(it) == [1, 2, 3] + + def test_show_true_calls_typer_progressbar(self, mocker): + mock_pb = mocker.patch("docvet.cli._runners.typer.progressbar") + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=iter([1, 2, 3])) + ctx.__exit__ = MagicMock(return_value=False) + mock_pb.return_value = ctx + + from docvet.cli._runners import _maybe_progressbar + + with _maybe_progressbar([1, 2, 3], label="test", show=True) as it: + result = list(it) + + assert result == [1, 2, 3] + mock_pb.assert_called_once_with([1, 2, 3], label="test", file=sys.stderr) + + def test_show_false_does_not_call_typer_progressbar(self, mocker): + mock_pb = mocker.patch("docvet.cli._runners.typer.progressbar") + + from docvet.cli._runners import _maybe_progressbar + + with _maybe_progressbar([1, 2], label="test", show=False) as it: + list(it) + + mock_pb.assert_not_called() From 4b8dd83c56db0cf87167577a1c2c7d85ad6ea366 Mon Sep 17 00:00:00 2001 From: Alberto-Codes Date: Fri, 8 May 2026 14:10:44 -0700 Subject: [PATCH 2/2] chore(deps): remove stale pygments GHSA ignore Pygments was bumped to 2.20.0 (GHSA-5239-wwwm-4pmq fixed), so the uv-secure ignore entry is now unused. --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9b966e5..1903380 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,8 +94,7 @@ explicit = true # uv-secure # --------------------------------------------------------------------------- # [tool.uv-secure.vulnerability_criteria] -# Pygments 2.19.2 ReDoS in AdlLexer (GHSA low severity, local-only). No fix available. -ignore_vulnerabilities = ["GHSA-5239-wwwm-4pmq"] +ignore_vulnerabilities = [] allow_unused_ignores = false # --------------------------------------------------------------------------- #