Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ Vidar T. Fauske
Vijay Arora
Virendra Patil
Virgil Dupras
Vishal Kumar
Vitaly Lashmanov
Vivaan Verma
Vlad Dragos
Expand Down
1 change: 1 addition & 0 deletions changelog/14983.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions doc/en/how-to/capture-warnings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------

Expand Down
40 changes: 40 additions & 0 deletions src/_pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 31 additions & 1 deletion src/_pytest/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -414,6 +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()
)

def _determine_show_progress_info(
self,
Expand Down Expand Up @@ -1086,6 +1090,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
Expand Down Expand Up @@ -1123,7 +1152,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(
Expand Down
164 changes: 164 additions & 0 deletions testing/test_warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,170 @@ 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."""
Expand Down