diff --git a/changelog/6653.improvement.rst b/changelog/6653.improvement.rst new file mode 100644 index 00000000000..4c081e6730c --- /dev/null +++ b/changelog/6653.improvement.rst @@ -0,0 +1 @@ +Add support for matching lines consecutively with :attr:`LineMatcher <_pytest.pytester.LineMatcher>`'s :func:`~_pytest.pytester.LineMatcher.fnmatch_lines` and :func:`~_pytest.pytester.LineMatcher.re_match_lines`. diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index cfe1b9a6ca5..f80a62e6fde 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -413,8 +413,8 @@ class RunResult: def __init__( self, ret: Union[int, ExitCode], - outlines: Sequence[str], - errlines: Sequence[str], + outlines: List[str], + errlines: List[str], duration: float, ) -> None: try: @@ -1327,49 +1327,32 @@ class LineMatcher: The constructor takes a list of lines without their trailing newlines, i.e. ``text.splitlines()``. - """ - def __init__(self, lines): + def __init__(self, lines: List[str]) -> None: self.lines = lines - self._log_output = [] + self._log_output = [] # type: List[str] - def str(self): - """Return the entire original text.""" - return "\n".join(self.lines) - - def _getlines(self, lines2): + def _getlines(self, lines2: Union[str, Sequence[str], Source]) -> Sequence[str]: if isinstance(lines2, str): lines2 = Source(lines2) if isinstance(lines2, Source): lines2 = lines2.strip().lines return lines2 - def fnmatch_lines_random(self, lines2): - """Check lines exist in the output using in any order. - - Lines are checked using ``fnmatch.fnmatch``. The argument is a list of - lines which have to occur in the output, in any order. - + def fnmatch_lines_random(self, lines2: Sequence[str]) -> None: + """Check lines exist in the output in any order (using :func:`python:fnmatch.fnmatch`). """ self._match_lines_random(lines2, fnmatch) - def re_match_lines_random(self, lines2): - """Check lines exist in the output using ``re.match``, in any order. - - The argument is a list of lines which have to occur in the output, in - any order. - + def re_match_lines_random(self, lines2: Sequence[str]) -> None: + """Check lines exist in the output in any order (using :func:`python:re.match`). """ - self._match_lines_random(lines2, lambda name, pat: re.match(pat, name)) - - def _match_lines_random(self, lines2, match_func): - """Check lines exist in the output. - - The argument is a list of lines which have to occur in the output, in - any order. Each line can contain glob whildcards. + self._match_lines_random(lines2, lambda name, pat: bool(re.match(pat, name))) - """ + def _match_lines_random( + self, lines2: Sequence[str], match_func: Callable[[str, str], bool] + ) -> None: lines2 = self._getlines(lines2) for line in lines2: for x in self.lines: @@ -1380,46 +1363,67 @@ def _match_lines_random(self, lines2, match_func): self._log("line %r not found in output" % line) raise ValueError(self._log_text) - def get_lines_after(self, fnline): + def get_lines_after(self, fnline: str) -> Sequence[str]: """Return all lines following the given line in the text. The given line can contain glob wildcards. - """ for i, line in enumerate(self.lines): if fnline == line or fnmatch(line, fnline): return self.lines[i + 1 :] raise ValueError("line %r not found in output" % fnline) - def _log(self, *args): + def _log(self, *args) -> None: self._log_output.append(" ".join(str(x) for x in args)) @property - def _log_text(self): + def _log_text(self) -> str: return "\n".join(self._log_output) - def fnmatch_lines(self, lines2): - """Search captured text for matching lines using ``fnmatch.fnmatch``. + def fnmatch_lines( + self, lines2: Sequence[str], *, consecutive: bool = False + ) -> None: + """Check lines exist in the output (using :func:`python:fnmatch.fnmatch`). The argument is a list of lines which have to match and can use glob wildcards. If they do not match a pytest.fail() is called. The matches and non-matches are also shown as part of the error message. + + :param lines2: string patterns to match. + :param consecutive: match lines consecutive? """ __tracebackhide__ = True - self._match_lines(lines2, fnmatch, "fnmatch") + self._match_lines(lines2, fnmatch, "fnmatch", consecutive=consecutive) - def re_match_lines(self, lines2): - """Search captured text for matching lines using ``re.match``. + def re_match_lines( + self, lines2: Sequence[str], *, consecutive: bool = False + ) -> None: + """Check lines exist in the output (using :func:`python:re.match`). The argument is a list of lines which have to match using ``re.match``. If they do not match a pytest.fail() is called. The matches and non-matches are also shown as part of the error message. + + :param lines2: string patterns to match. + :param consecutive: match lines consecutively? """ __tracebackhide__ = True - self._match_lines(lines2, lambda name, pat: re.match(pat, name), "re.match") + self._match_lines( + lines2, + lambda name, pat: bool(re.match(pat, name)), + "re.match", + consecutive=consecutive, + ) - def _match_lines(self, lines2, match_func, match_nickname): + def _match_lines( + self, + lines2: Sequence[str], + match_func: Callable[[str, str], bool], + match_nickname: str, + *, + consecutive: bool = False + ) -> None: """Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``. :param list[str] lines2: list of string patterns to match. The actual @@ -1429,28 +1433,40 @@ def _match_lines(self, lines2, match_func, match_nickname): pattern :param str match_nickname: the nickname for the match function that will be logged to stdout when a match occurs + :param consecutive: match lines consecutively? """ - assert isinstance(lines2, collections.abc.Sequence) + if not isinstance(lines2, collections.abc.Sequence): + raise TypeError("invalid type for lines2: {}".format(type(lines2).__name__)) lines2 = self._getlines(lines2) lines1 = self.lines[:] nextline = None extralines = [] __tracebackhide__ = True wnick = len(match_nickname) + 1 + started = False for line in lines2: nomatchprinted = False while lines1: nextline = lines1.pop(0) if line == nextline: self._log("exact match:", repr(line)) + started = True break elif match_func(nextline, line): self._log("%s:" % match_nickname, repr(line)) self._log( "{:>{width}}".format("with:", width=wnick), repr(nextline) ) + started = True break else: + if consecutive and started: + msg = "no consecutive match: {!r}".format(line) + self._log(msg) + self._log( + "{:>{width}}".format("with:", width=wnick), repr(nextline) + ) + self._fail(msg) if not nomatchprinted: self._log( "{:>{width}}".format("nomatch:", width=wnick), repr(line) @@ -1464,7 +1480,7 @@ def _match_lines(self, lines2, match_func, match_nickname): self._fail(msg) self._log_output = [] - def no_fnmatch_line(self, pat): + def no_fnmatch_line(self, pat: str) -> None: """Ensure captured lines do not match the given pattern, using ``fnmatch.fnmatch``. :param str pat: the pattern to match lines. @@ -1472,15 +1488,19 @@ def no_fnmatch_line(self, pat): __tracebackhide__ = True self._no_match_line(pat, fnmatch, "fnmatch") - def no_re_match_line(self, pat): + def no_re_match_line(self, pat: str) -> None: """Ensure captured lines do not match the given pattern, using ``re.match``. :param str pat: the regular expression to match lines. """ __tracebackhide__ = True - self._no_match_line(pat, lambda name, pat: re.match(pat, name), "re.match") + self._no_match_line( + pat, lambda name, pat: bool(re.match(pat, name)), "re.match" + ) - def _no_match_line(self, pat, match_func, match_nickname): + def _no_match_line( + self, pat: str, match_func: Callable[[str, str], bool], match_nickname: str + ) -> None: """Ensure captured lines does not have a the given pattern, using ``fnmatch.fnmatch`` :param str pat: the pattern to match lines @@ -1501,8 +1521,12 @@ def _no_match_line(self, pat, match_func, match_nickname): self._log("{:>{width}}".format("and:", width=wnick), repr(line)) self._log_output = [] - def _fail(self, msg): + def _fail(self, msg: str) -> None: __tracebackhide__ = True log_text = self._log_text self._log_output = [] pytest.fail(log_text) + + def str(self) -> str: + """Return the entire original text.""" + return "\n".join(self.lines) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 35a06e33a32..bc0d9d0c3e5 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -458,17 +458,26 @@ def test_timeout(): def test_linematcher_with_nonlist() -> None: """Test LineMatcher with regard to passing in a set (accidentally).""" - lm = LineMatcher([]) + from _pytest._code.source import Source - with pytest.raises(AssertionError): - lm.fnmatch_lines(set()) - with pytest.raises(AssertionError): - lm.fnmatch_lines({}) + lm = LineMatcher([]) + with pytest.raises(TypeError, match="invalid type for lines2: set"): + lm.fnmatch_lines(set()) # type: ignore[arg-type] # noqa: F821 + with pytest.raises(TypeError, match="invalid type for lines2: dict"): + lm.fnmatch_lines({}) # type: ignore[arg-type] # noqa: F821 + with pytest.raises(TypeError, match="invalid type for lines2: set"): + lm.re_match_lines(set()) # type: ignore[arg-type] # noqa: F821 + with pytest.raises(TypeError, match="invalid type for lines2: dict"): + lm.re_match_lines({}) # type: ignore[arg-type] # noqa: F821 + with pytest.raises(TypeError, match="invalid type for lines2: Source"): + lm.fnmatch_lines(Source()) # type: ignore[arg-type] # noqa: F821 lm.fnmatch_lines([]) lm.fnmatch_lines(()) - - assert lm._getlines({}) == {} - assert lm._getlines(set()) == set() + lm.fnmatch_lines("") + assert lm._getlines({}) == {} # type: ignore[arg-type,comparison-overlap] # noqa: F821 + assert lm._getlines(set()) == set() # type: ignore[arg-type,comparison-overlap] # noqa: F821 + assert lm._getlines(Source()) == [] + assert lm._getlines(Source("pass\npass")) == ["pass", "pass"] def test_linematcher_match_failure() -> None: @@ -499,8 +508,28 @@ def test_linematcher_match_failure() -> None: ] +def test_linematcher_consecutive(): + lm = LineMatcher(["1", "", "2"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines(["1", "2"], consecutive=True) + assert str(excinfo.value).splitlines() == [ + "exact match: '1'", + "no consecutive match: '2'", + " with: ''", + ] + + lm.re_match_lines(["1", r"\d?", "2"], consecutive=True) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.re_match_lines(["1", r"\d", "2"], consecutive=True) + assert str(excinfo.value).splitlines() == [ + "exact match: '1'", + r"no consecutive match: '\\d'", + " with: ''", + ] + + @pytest.mark.parametrize("function", ["no_fnmatch_line", "no_re_match_line"]) -def test_no_matching(function) -> None: +def test_linematcher_no_matching(function) -> None: if function == "no_fnmatch_line": good_pattern = "*.py OK*" bad_pattern = "*X.py OK*" @@ -548,7 +577,7 @@ def test_no_matching(function) -> None: func(bad_pattern) # bad pattern does not match any line: passes -def test_no_matching_after_match() -> None: +def test_linematcher_no_matching_after_match() -> None: lm = LineMatcher(["1", "2", "3"]) lm.fnmatch_lines(["1", "3"]) with pytest.raises(Failed) as e: