From 09a0e454923f3c22bc985ef42e789c5e0b0c0179 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 21:41:25 +0100 Subject: [PATCH 1/5] testing/test_pytester.py: cosmetics --- testing/test_pytester.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 35a06e33a32..2e61632fd8f 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -459,14 +459,12 @@ def test_timeout(): def test_linematcher_with_nonlist() -> None: """Test LineMatcher with regard to passing in a set (accidentally).""" lm = LineMatcher([]) - with pytest.raises(AssertionError): lm.fnmatch_lines(set()) with pytest.raises(AssertionError): lm.fnmatch_lines({}) lm.fnmatch_lines([]) lm.fnmatch_lines(()) - assert lm._getlines({}) == {} assert lm._getlines(set()) == set() @@ -500,7 +498,7 @@ def test_linematcher_match_failure() -> None: @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 +546,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: From b10ab0211c789ea28bb372fe93fffeeb8cd338ad Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 22:06:15 +0100 Subject: [PATCH 2/5] Use TypeError instead of AssertionError for no sequence Improve/extends tests. --- src/_pytest/pytester.py | 3 ++- testing/test_pytester.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index cfe1b9a6ca5..82a7a7bbed4 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1430,7 +1430,8 @@ def _match_lines(self, lines2, match_func, match_nickname): :param str match_nickname: the nickname for the match function that will be logged to stdout when a match occurs """ - 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 diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 2e61632fd8f..da5fb99a14f 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -458,15 +458,26 @@ def test_timeout(): def test_linematcher_with_nonlist() -> None: """Test LineMatcher with regard to passing in a set (accidentally).""" + from _pytest._code.source import Source + lm = LineMatcher([]) - with pytest.raises(AssertionError): + with pytest.raises(TypeError, match="invalid type for lines2: set"): lm.fnmatch_lines(set()) - with pytest.raises(AssertionError): + with pytest.raises(TypeError, match="invalid type for lines2: dict"): lm.fnmatch_lines({}) + with pytest.raises(TypeError, match="invalid type for lines2: set"): + lm.re_match_lines(set()) + with pytest.raises(TypeError, match="invalid type for lines2: dict"): + lm.re_match_lines({}) + with pytest.raises(TypeError, match="invalid type for lines2: Source"): + lm.fnmatch_lines(Source()) lm.fnmatch_lines([]) lm.fnmatch_lines(()) + lm.fnmatch_lines("") assert lm._getlines({}) == {} assert lm._getlines(set()) == set() + assert lm._getlines(Source()) == [] + assert lm._getlines(Source("pass\npass")) == ["pass", "pass"] def test_linematcher_match_failure() -> None: From 2681b0aed7b1c92e6971bbbff527321958d6e627 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 22:30:34 +0100 Subject: [PATCH 3/5] typing: pytester: LineMatcher --- src/_pytest/pytester.py | 68 ++++++++++++++++++++++------------------ testing/test_pytester.py | 14 ++++----- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 82a7a7bbed4..f77845ca4c9 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,48 +1327,42 @@ 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 = [] - - def str(self): - """Return the entire original text.""" - return "\n".join(self.lines) + self._log_output = [] # type: List[str] - 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): + def fnmatch_lines_random(self, lines2: Sequence[str]) -> None: """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. - """ self._match_lines_random(lines2, fnmatch) - def re_match_lines_random(self, lines2): + def re_match_lines_random(self, lines2: Sequence[str]) -> None: """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. - """ - self._match_lines_random(lines2, lambda name, pat: re.match(pat, name)) + self._match_lines_random(lines2, lambda name, pat: bool(re.match(pat, name))) - def _match_lines_random(self, lines2, match_func): + def _match_lines_random( + self, lines2: Sequence[str], match_func: Callable[[str, str], bool] + ) -> None: """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. - """ lines2 = self._getlines(lines2) for line in lines2: @@ -1380,25 +1374,24 @@ 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): + def fnmatch_lines(self, lines2: Sequence[str]) -> None: """Search captured text for matching lines using ``fnmatch.fnmatch``. The argument is a list of lines which have to match and can use glob @@ -1408,7 +1401,7 @@ def fnmatch_lines(self, lines2): __tracebackhide__ = True self._match_lines(lines2, fnmatch, "fnmatch") - def re_match_lines(self, lines2): + def re_match_lines(self, lines2: Sequence[str]) -> None: """Search captured text for matching lines using ``re.match``. The argument is a list of lines which have to match using ``re.match``. @@ -1417,9 +1410,16 @@ def re_match_lines(self, lines2): The matches and non-matches are also shown as part of the error message. """ __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" + ) - 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, + ) -> None: """Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``. :param list[str] lines2: list of string patterns to match. The actual @@ -1465,7 +1465,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. @@ -1473,15 +1473,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 @@ -1502,8 +1506,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 da5fb99a14f..a6901e9677c 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -462,20 +462,20 @@ def test_linematcher_with_nonlist() -> None: lm = LineMatcher([]) with pytest.raises(TypeError, match="invalid type for lines2: set"): - lm.fnmatch_lines(set()) + lm.fnmatch_lines(set()) # type: ignore[arg-type] # noqa: F821 with pytest.raises(TypeError, match="invalid type for lines2: dict"): - lm.fnmatch_lines({}) + lm.fnmatch_lines({}) # type: ignore[arg-type] # noqa: F821 with pytest.raises(TypeError, match="invalid type for lines2: set"): - lm.re_match_lines(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({}) + lm.re_match_lines({}) # type: ignore[arg-type] # noqa: F821 with pytest.raises(TypeError, match="invalid type for lines2: Source"): - lm.fnmatch_lines(Source()) + lm.fnmatch_lines(Source()) # type: ignore[arg-type] # noqa: F821 lm.fnmatch_lines([]) lm.fnmatch_lines(()) lm.fnmatch_lines("") - assert lm._getlines({}) == {} - assert lm._getlines(set()) == set() + 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"] From 50f81db8175a6195b6efcf92ec9e1dd4bd51efcc Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 23:28:27 +0100 Subject: [PATCH 4/5] revisit/improve docstrings --- src/_pytest/pytester.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index f77845ca4c9..11f81b76e54 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1341,29 +1341,18 @@ def _getlines(self, lines2: Union[str, Sequence[str], Source]) -> Sequence[str]: return lines2 def fnmatch_lines_random(self, lines2: Sequence[str]) -> None: - """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. + """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: Sequence[str]) -> None: - """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. + """Check lines exist in the output in any order (using :func:`python:re.match`). """ 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: - """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. - """ lines2 = self._getlines(lines2) for line in lines2: for x in self.lines: @@ -1392,22 +1381,26 @@ def _log_text(self) -> str: return "\n".join(self._log_output) def fnmatch_lines(self, lines2: Sequence[str]) -> None: - """Search captured text for matching lines using ``fnmatch.fnmatch``. + """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. """ __tracebackhide__ = True self._match_lines(lines2, fnmatch, "fnmatch") def re_match_lines(self, lines2: Sequence[str]) -> None: - """Search captured text for matching lines using ``re.match``. + """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. """ __tracebackhide__ = True self._match_lines( From 5256542ea41583e1c02783b2650b7c8a23749088 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 1 Feb 2020 23:33:24 +0100 Subject: [PATCH 5/5] pytester.LineMatcher: add support for matching lines consecutively --- changelog/6653.improvement.rst | 1 + src/_pytest/pytester.py | 30 ++++++++++++++++++++++++++---- testing/test_pytester.py | 20 ++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 changelog/6653.improvement.rst 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 11f81b76e54..f80a62e6fde 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1380,7 +1380,9 @@ def _log(self, *args) -> None: def _log_text(self) -> str: return "\n".join(self._log_output) - def fnmatch_lines(self, lines2: Sequence[str]) -> None: + 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 @@ -1388,11 +1390,14 @@ def fnmatch_lines(self, lines2: Sequence[str]) -> None: 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: Sequence[str]) -> None: + 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``. @@ -1401,10 +1406,14 @@ def re_match_lines(self, lines2: Sequence[str]) -> None: 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: bool(re.match(pat, name)), "re.match" + lines2, + lambda name, pat: bool(re.match(pat, name)), + "re.match", + consecutive=consecutive, ) def _match_lines( @@ -1412,6 +1421,8 @@ def _match_lines( 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``. @@ -1422,6 +1433,7 @@ def _match_lines( 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? """ if not isinstance(lines2, collections.abc.Sequence): raise TypeError("invalid type for lines2: {}".format(type(lines2).__name__)) @@ -1431,20 +1443,30 @@ def _match_lines( 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) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index a6901e9677c..bc0d9d0c3e5 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -508,6 +508,26 @@ 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_linematcher_no_matching(function) -> None: if function == "no_fnmatch_line":