From 0a5ce194575b57521d98f3421fa7289063f3cfbb Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sat, 12 Sep 2026 19:58:32 +0700 Subject: [PATCH 1/2] Add complete option to LineMatcher methods Co-authored-by: Claude Sonnet 5 --- changelog/6820.improvement.rst | 1 + src/_pytest/pytester.py | 28 +++++++++++++++++--- testing/test_pytester.py | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 changelog/6820.improvement.rst diff --git a/changelog/6820.improvement.rst b/changelog/6820.improvement.rst new file mode 100644 index 00000000000..75f709a88ea --- /dev/null +++ b/changelog/6820.improvement.rst @@ -0,0 +1 @@ +Add support for asserting complete output with :class:`~pytest.LineMatcher`'s :func:`~pytest.LineMatcher.fnmatch_lines` and :func:`~pytest.LineMatcher.re_match_lines` via a new ``complete`` option. diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index bbc740e9582..07798c01752 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1653,7 +1653,11 @@ def _log_text(self) -> str: return "\n".join(self._log_output) def fnmatch_lines( - self, lines2: Sequence[str], *, consecutive: bool = False + self, + lines2: Sequence[str], + *, + consecutive: bool = False, + complete: bool = False, ) -> None: """Check lines exist in the output (using :func:`python:fnmatch.fnmatch`). @@ -1663,12 +1667,19 @@ def fnmatch_lines( :param lines2: String patterns to match. :param consecutive: Match lines consecutively? + :param complete: Fail if the output contains lines which were not asserted? """ __tracebackhide__ = True - self._match_lines(lines2, fnmatch, "fnmatch", consecutive=consecutive) + self._match_lines( + lines2, fnmatch, "fnmatch", consecutive=consecutive, complete=complete + ) def re_match_lines( - self, lines2: Sequence[str], *, consecutive: bool = False + self, + lines2: Sequence[str], + *, + consecutive: bool = False, + complete: bool = False, ) -> None: """Check lines exist in the output (using :func:`python:re.match`). @@ -1679,6 +1690,7 @@ def re_match_lines( :param lines2: string patterns to match. :param consecutive: match lines consecutively? + :param complete: Fail if the output contains lines which were not asserted? """ __tracebackhide__ = True self._match_lines( @@ -1686,6 +1698,7 @@ def re_match_lines( lambda name, pat: bool(re.match(pat, name)), "re.match", consecutive=consecutive, + complete=complete, ) def _match_lines( @@ -1695,6 +1708,7 @@ def _match_lines( match_nickname: str, *, consecutive: bool = False, + complete: bool = False, ) -> None: """Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``. @@ -1710,6 +1724,8 @@ def _match_lines( when a match occurs. :param consecutive: Match lines consecutively? + :param complete: + Fail if the output contains lines which were not asserted? """ if not isinstance(lines2, collections.abc.Sequence): raise TypeError(f"invalid type for lines2: {type(lines2).__name__}") @@ -1753,6 +1769,12 @@ def _match_lines( msg = f"remains unmatched: {line!r}" self._log(msg) self._fail(msg) + if complete: + unasserted = extralines + lines1 + if unasserted: + msg = f"output has unasserted lines: {unasserted!r}" + self._log(msg) + self._fail(msg) self._log_output = [] def no_fnmatch_line(self, pat: str) -> None: diff --git a/testing/test_pytester.py b/testing/test_pytester.py index e9a20f1063c..02a9cf3cfc1 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -549,6 +549,53 @@ def test_linematcher_consecutive() -> None: ] +def test_linematcher_complete() -> None: + lm = LineMatcher(["1", "2", "3"]) + lm.fnmatch_lines(["1", "2", "3"], complete=True) + lm.re_match_lines(["1", "2", "3"], complete=True) + + lm = LineMatcher(["unexpected", "1", "2", "3"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines(["1", "2", "3"], complete=True) + assert str(excinfo.value).splitlines() == [ + "nomatch: '1'", + " and: 'unexpected'", + "exact match: '1'", + "exact match: '2'", + "exact match: '3'", + "output has unasserted lines: ['unexpected']", + ] + + lm = LineMatcher(["1", "2", "3", "trailing"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.re_match_lines(["1", "2", "3"], complete=True) + assert str(excinfo.value).splitlines() == [ + "exact match: '1'", + "exact match: '2'", + "exact match: '3'", + "output has unasserted lines: ['trailing']", + ] + + lm = LineMatcher(["0", "1", "2", "3"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines(["1", "2", "3"], consecutive=True, complete=True) + assert str(excinfo.value).splitlines() == [ + "nomatch: '1'", + " and: '0'", + "exact match: '1'", + "exact match: '2'", + "exact match: '3'", + "output has unasserted lines: ['0']", + ] + + lm = LineMatcher(["x"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines([], complete=True) + assert str(excinfo.value).splitlines() == [ + "output has unasserted lines: ['x']", + ] + + @pytest.mark.parametrize("function", ["no_fnmatch_line", "no_re_match_line"]) def test_linematcher_no_matching(function: str) -> None: if function == "no_fnmatch_line": From 0c949d7345379c60bbbf04e1066679416f12f8ba Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sat, 12 Sep 2026 21:33:31 +0700 Subject: [PATCH 2/2] Add duplicate-output test for LineMatcher complete mode --- testing/test_pytester.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 02a9cf3cfc1..fb133f2ca38 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -595,6 +595,14 @@ def test_linematcher_complete() -> None: "output has unasserted lines: ['x']", ] + lm = LineMatcher(["1", "1"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines(["1"], complete=True) + assert str(excinfo.value).splitlines() == [ + "exact match: '1'", + "output has unasserted lines: ['1']", + ] + @pytest.mark.parametrize("function", ["no_fnmatch_line", "no_re_match_line"]) def test_linematcher_no_matching(function: str) -> None: