From a7a0eaf4135b45dda2ace84c58ae09eeefb84c27 Mon Sep 17 00:00:00 2001 From: Vishal Kumar Date: Mon, 7 Sep 2026 21:28:25 +0100 Subject: [PATCH 1/2] Add --warnings-collapse-threshold option to control warning location collapsing Fixes #14983. --- AUTHORS | 1 + changelog/14983.feature.rst | 1 + doc/en/how-to/capture-warnings.rst | 56 ++++++++++ src/_pytest/main.py | 40 +++++++ src/_pytest/terminal.py | 30 +++++- testing/test_warnings.py | 166 +++++++++++++++++++++++++++++ 6 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 changelog/14983.feature.rst diff --git a/AUTHORS b/AUTHORS index 093d5fd472a..7c54497721c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -510,6 +510,7 @@ Vidar T. Fauske Vijay Arora Virendra Patil Virgil Dupras +Vishal Kumar Vitaly Lashmanov Vivaan Verma Vlad Dragos diff --git a/changelog/14983.feature.rst b/changelog/14983.feature.rst new file mode 100644 index 00000000000..fe27cfd3b0a --- /dev/null +++ b/changelog/14983.feature.rst @@ -0,0 +1 @@ +Added :option:`--warnings-collapse-threshold` CLI option and :confval:`warnings_collapse_threshold` ini setting to control the number of warning locations at which the warnings summary collapses to filename-only output. Defaults to ``10``; set to ``none`` to never collapse and always show full test node IDs. diff --git a/doc/en/how-to/capture-warnings.rst b/doc/en/how-to/capture-warnings.rst index d9336c0a144..60cb0927fe1 100644 --- a/doc/en/how-to/capture-warnings.rst +++ b/doc/en/how-to/capture-warnings.rst @@ -244,6 +244,62 @@ The threshold can also be set in the configuration file using :confval:`max_warn regardless of the warning count. ``MAX_WARNINGS_ERROR`` is only reported when all tests pass but the warning threshold is exceeded. +Controlling warning location collapsing +---------------------------------------- + +.. versionadded:: 9.2 + +When many tests trigger the same warning, pytest collapses the warnings summary +to show only filenames rather than individual test node IDs. By default this +happens once a warning appears in 10 or more locations. + +You can change this threshold with the :option:`--warnings-collapse-threshold` +command-line option: + +.. code-block:: bash + + pytest --warnings-collapse-threshold=20 + +Set it to ``none`` to always show full test node IDs regardless of how many +locations a warning appears in: + +.. code-block:: bash + + pytest --warnings-collapse-threshold=none + +The threshold can also be set in the configuration file using +:confval:`warnings_collapse_threshold`: + +.. tab:: toml + + .. code-block:: toml + + [pytest] + warnings_collapse_threshold = 20 + +.. tab:: ini + + .. code-block:: ini + + [pytest] + warnings_collapse_threshold = 20 + +To never collapse, set the value to ``none`` in the configuration file: + +.. tab:: toml + + .. code-block:: toml + + [pytest] + warnings_collapse_threshold = "none" + +.. tab:: ini + + .. code-block:: ini + + [pytest] + warnings_collapse_threshold = none + Disabling warnings summary -------------------------- diff --git a/src/_pytest/main.py b/src/_pytest/main.py index d43a68b4679..e6472fbf3e4 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -55,6 +55,23 @@ from _pytest.fixtures import FixtureManager +def _warnings_collapse_threshold_type(value: str) -> str: + """Argparse type for --warnings-collapse-threshold: positive int or 'none'.""" + if value.lower() == "none": + return value + try: + int_val = int(value) + except ValueError: + raise argparse.ArgumentTypeError( + f"warnings_collapse_threshold must be a positive integer or 'none', got {value!r}" + ) + if int_val <= 0: + raise argparse.ArgumentTypeError( + f"warnings_collapse_threshold must be a positive integer or 'none', got {int_val}" + ) + return value + + def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group._addoption( # private to use reserved lower-case short option @@ -148,6 +165,29 @@ def pytest_addoption(parser: Parser) -> None: type=int | str, default=None, ) + group.addoption( + "--warnings-collapse-threshold", + action="store", + type=_warnings_collapse_threshold_type, + default=None, + metavar="num|none", + dest="warnings_collapse_threshold", + help=( + "Number of locations at which to start collapsing warnings to filenames only " + "(default: 10, 'none' to never collapse; " + "threshold=1 collapses all warnings including single-location ones)" + ), + ) + parser.addini( + "warnings_collapse_threshold", + help=( + "Number of locations at which to start collapsing warnings to filenames only " + "(default: 10, 'none' to never collapse; " + "threshold=1 collapses all warnings including single-location ones)" + ), + type=int | str, + default=None, + ) group = parser.getgroup("collect", "collection") group.addoption( diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 023fdcaabb8..f49cbd638d0 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -44,6 +44,7 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl +from _pytest.config import UsageError from _pytest.config.argparsing import Parser from _pytest.nodeid import NodeId from _pytest.nodes import Item @@ -414,6 +415,7 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: self._collect_report_last_write = timing.Instant() self._already_displayed_warnings: int | None = None self._keyboardinterrupt_memo: ExceptionRepr | None = None + self._warnings_collapse_threshold: int | None = self._get_warnings_collapse_threshold() def _determine_show_progress_info( self, @@ -1086,6 +1088,31 @@ def _getcrashline(self, rep): except AttributeError: return "" + def _get_warnings_collapse_threshold(self) -> int | None: + """Return the warnings collapse threshold, from CLI or INI, defaulting to 10. + + Returns None to indicate no threshold (never collapse). + Raises UsageError if the value is not a positive integer or 'none'. + """ + raw: int | str | None = self.config.option.warnings_collapse_threshold + if raw is None: + raw = self.config.getini("warnings_collapse_threshold") + if raw is None: + return 10 + if isinstance(raw, str) and raw.lower() == "none": + return None + try: + value = int(raw) + except (ValueError, TypeError): + raise UsageError( + f"warnings_collapse_threshold must be a positive integer or 'none', got {raw!r}" + ) + if value <= 0: + raise UsageError( + f"warnings_collapse_threshold must be a positive integer or 'none', got {value}" + ) + return value + def _get_max_warnings(self) -> int | None: """Return the max_warnings threshold, from CLI or INI, or None if unset.""" value = self.config.option.max_warnings @@ -1123,7 +1150,8 @@ def summary_warnings(self) -> None: def collapsed_location_report(reports: list[WarningReport]) -> str: locations = [x for w in reports if (x := w.get_location(self.config))] - if len(locations) < 10: + threshold = self._warnings_collapse_threshold + if threshold is None or len(locations) < threshold: return "\n".join(map(str, locations)) counts_by_filename = Counter( diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 0344cb453d8..4c56b89fbdd 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1163,6 +1163,172 @@ def test_one(): assert result.ret == ExitCode.OK +class TestWarningsCollapseThreshold: + """Tests for the --warnings-collapse-threshold feature.""" + + # A shared helper function is the warning source (stacklevel=1), + # so all tests produce the same formatted message and group together. + PYFILE = """ + import warnings + + def warn(): + warnings.warn("shared warning", UserWarning) + + def test_one(): warn() + def test_two(): warn() + def test_three(): warn() + def test_four(): warn() + def test_five(): warn() + def test_six(): warn() + def test_seven(): warn() + def test_eight(): warn() + def test_nine(): warn() + def test_ten(): warn() + def test_eleven(): warn() + """ + + @pytest.mark.filterwarnings("always::UserWarning") + def test_default_collapses_at_10(self, pytester: Pytester) -> None: + """By default, 10+ locations collapse to filename-only output.""" + pytester.makepyfile(self.PYFILE) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["test_default_collapses_at_10.py: 11 warnings"]) + result.stdout.no_fnmatch_line("*::test_one*") + + @pytest.mark.filterwarnings("always::UserWarning") + def test_below_default_threshold_shows_full_locations( + self, pytester: Pytester + ) -> None: + """Fewer than 10 locations are shown in full (default threshold).""" + pytester.makepyfile( + """ + import warnings + + def warn(): + warnings.warn("shared warning", UserWarning) + + def test_one(): warn() + def test_two(): warn() + def test_three(): warn() + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*test_below_default_threshold_shows_full_locations.py::test_one*", + "*test_below_default_threshold_shows_full_locations.py::test_two*", + "*test_below_default_threshold_shows_full_locations.py::test_three*", + ] + ) + + @pytest.mark.filterwarnings("always::UserWarning") + def test_threshold_none_never_collapses(self, pytester: Pytester) -> None: + """--warnings-collapse-threshold=none disables collapsing entirely.""" + pytester.makepyfile(self.PYFILE) + result = pytester.runpytest("--warnings-collapse-threshold=none") + result.stdout.fnmatch_lines( + [ + "*::test_one*", + "*::test_eleven*", + ] + ) + result.stdout.no_fnmatch_line("*: 11 warnings*") + + @pytest.mark.filterwarnings("always::UserWarning") + def test_custom_threshold_collapses(self, pytester: Pytester) -> None: + """A custom threshold collapses when location count meets or exceeds it.""" + pytester.makepyfile(self.PYFILE) + result = pytester.runpytest("--warnings-collapse-threshold=5") + result.stdout.fnmatch_lines(["test_custom_threshold_collapses.py: 11 warnings"]) + result.stdout.no_fnmatch_line("*::test_one*") + + @pytest.mark.filterwarnings("always::UserWarning") + def test_custom_threshold_below_count_shows_full( + self, pytester: Pytester + ) -> None: + """Locations below a raised threshold are shown in full.""" + pytester.makepyfile(self.PYFILE) + result = pytester.runpytest("--warnings-collapse-threshold=20") + result.stdout.fnmatch_lines( + [ + "*::test_one*", + "*::test_eleven*", + ] + ) + result.stdout.no_fnmatch_line("*: 11 warnings*") + + @pytest.mark.filterwarnings("always::UserWarning") + def test_ini_option(self, pytester: Pytester) -> None: + """warnings_collapse_threshold can be set via INI configuration.""" + pytester.makeini( + """ + [pytest] + warnings_collapse_threshold = 5 + """ + ) + pytester.makepyfile(self.PYFILE) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["test_ini_option.py: 11 warnings"]) + result.stdout.no_fnmatch_line("*::test_one*") + + @pytest.mark.filterwarnings("always::UserWarning") + @pytest.mark.parametrize("value", ["5", '"5"']) + def test_toml_option(self, pytester: Pytester, value: str) -> None: + """warnings_collapse_threshold can be set via TOML configuration.""" + pytester.maketoml( + f""" + [pytest] + warnings_collapse_threshold = {value} + """ + ) + pytester.makepyfile(self.PYFILE) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["test_toml_option.py: 11 warnings"]) + result.stdout.no_fnmatch_line("*::test_one*") + + @pytest.mark.filterwarnings("always::UserWarning") + def test_cli_overrides_ini(self, pytester: Pytester) -> None: + """CLI --warnings-collapse-threshold overrides INI warnings_collapse_threshold.""" + pytester.makeini( + """ + [pytest] + warnings_collapse_threshold = 5 + """ + ) + pytester.makepyfile(self.PYFILE) + result = pytester.runpytest("--warnings-collapse-threshold=20") + result.stdout.fnmatch_lines(["*::test_one*", "*::test_eleven*"]) + result.stdout.no_fnmatch_line("*: 11 warnings*") + + @pytest.mark.parametrize("value", ["0", "-1"]) + def test_invalid_value_raises_error(self, pytester: Pytester, value: str) -> None: + """Zero or negative threshold raises UsageError.""" + pytester.makepyfile("def test_it(): pass") + result = pytester.runpytest(f"--warnings-collapse-threshold={value}") + result.stderr.fnmatch_lines( + ["*warnings_collapse_threshold must be a positive integer or 'none'*"] + ) + assert result.ret != ExitCode.OK + + @pytest.mark.parametrize("value", ["0", "-1"]) + def test_invalid_ini_value_raises_error( + self, pytester: Pytester, value: str + ) -> None: + """Zero or negative threshold in INI raises UsageError.""" + pytester.makeini( + f""" + [pytest] + warnings_collapse_threshold = {value} + """ + ) + pytester.makepyfile("def test_it(): pass") + result = pytester.runpytest() + result.stderr.fnmatch_lines( + ["*warnings_collapse_threshold must be a positive integer or 'none'*"] + ) + assert result.ret != ExitCode.OK + + def test_pythonwarnings_not_duplicated(pytester: Pytester) -> None: """Regression test for #13484: -W values should not be duplicated in known_args_namespace due to the arg parser being called multiple times.""" From 84f4149e40289f428c9db4e79f3d35abad82b616 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:46:12 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/terminal.py | 4 +++- testing/test_warnings.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index f49cbd638d0..c386a35b112 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -415,7 +415,9 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: self._collect_report_last_write = timing.Instant() self._already_displayed_warnings: int | None = None self._keyboardinterrupt_memo: ExceptionRepr | None = None - self._warnings_collapse_threshold: int | None = self._get_warnings_collapse_threshold() + self._warnings_collapse_threshold: int | None = ( + self._get_warnings_collapse_threshold() + ) def _determine_show_progress_info( self, diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 4c56b89fbdd..40471b474ff 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1243,9 +1243,7 @@ def test_custom_threshold_collapses(self, pytester: Pytester) -> None: result.stdout.no_fnmatch_line("*::test_one*") @pytest.mark.filterwarnings("always::UserWarning") - def test_custom_threshold_below_count_shows_full( - self, pytester: Pytester - ) -> None: + def test_custom_threshold_below_count_shows_full(self, pytester: Pytester) -> None: """Locations below a raised threshold are shown in full.""" pytester.makepyfile(self.PYFILE) result = pytester.runpytest("--warnings-collapse-threshold=20")