pytester: LineMatcher: typing, docs, consecutive line matching (#6653)

This commit is contained in:
Daniel Hahler 2020-02-04 22:47:18 +01:00 committed by GitHub
commit 39d9f7cff5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 111 additions and 57 deletions

View File

@ -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`.

View File

@ -413,8 +413,8 @@ class RunResult:
def __init__( def __init__(
self, self,
ret: Union[int, ExitCode], ret: Union[int, ExitCode],
outlines: Sequence[str], outlines: List[str],
errlines: Sequence[str], errlines: List[str],
duration: float, duration: float,
) -> None: ) -> None:
try: try:
@ -1318,49 +1318,32 @@ class LineMatcher:
The constructor takes a list of lines without their trailing newlines, i.e. The constructor takes a list of lines without their trailing newlines, i.e.
``text.splitlines()``. ``text.splitlines()``.
""" """
def __init__(self, lines): def __init__(self, lines: List[str]) -> None:
self.lines = lines self.lines = lines
self._log_output = [] self._log_output = [] # type: List[str]
def str(self): def _getlines(self, lines2: Union[str, Sequence[str], Source]) -> Sequence[str]:
"""Return the entire original text."""
return "\n".join(self.lines)
def _getlines(self, lines2):
if isinstance(lines2, str): if isinstance(lines2, str):
lines2 = Source(lines2) lines2 = Source(lines2)
if isinstance(lines2, Source): if isinstance(lines2, Source):
lines2 = lines2.strip().lines lines2 = lines2.strip().lines
return lines2 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. """Check lines exist in the output in any order (using :func:`python:fnmatch.fnmatch`).
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) 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. """Check lines exist in the output in any order (using :func:`python:re.match`).
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(
"""Check lines exist in the output. self, lines2: Sequence[str], match_func: Callable[[str, str], bool]
) -> None:
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) lines2 = self._getlines(lines2)
for line in lines2: for line in lines2:
for x in self.lines: for x in self.lines:
@ -1371,46 +1354,67 @@ class LineMatcher:
self._log("line %r not found in output" % line) self._log("line %r not found in output" % line)
raise ValueError(self._log_text) 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. """Return all lines following the given line in the text.
The given line can contain glob wildcards. The given line can contain glob wildcards.
""" """
for i, line in enumerate(self.lines): for i, line in enumerate(self.lines):
if fnline == line or fnmatch(line, fnline): if fnline == line or fnmatch(line, fnline):
return self.lines[i + 1 :] return self.lines[i + 1 :]
raise ValueError("line %r not found in output" % fnline) 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)) self._log_output.append(" ".join(str(x) for x in args))
@property @property
def _log_text(self): def _log_text(self) -> str:
return "\n".join(self._log_output) return "\n".join(self._log_output)
def fnmatch_lines(self, lines2): def fnmatch_lines(
"""Search captured text for matching lines using ``fnmatch.fnmatch``. 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 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 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. 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 __tracebackhide__ = True
self._match_lines(lines2, fnmatch, "fnmatch") self._match_lines(lines2, fnmatch, "fnmatch", consecutive=consecutive)
def re_match_lines(self, lines2): def re_match_lines(
"""Search captured text for matching lines using ``re.match``. 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``. The argument is a list of lines which have to match using ``re.match``.
If they do not match a pytest.fail() is called. If they do not match a pytest.fail() is called.
The matches and non-matches are also shown as part of the error message. 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 __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``. """Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``.
:param list[str] lines2: list of string patterns to match. The actual :param list[str] lines2: list of string patterns to match. The actual
@ -1420,28 +1424,40 @@ class LineMatcher:
pattern pattern
:param str match_nickname: the nickname for the match function that :param str match_nickname: the nickname for the match function that
will be logged to stdout when a match occurs 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) lines2 = self._getlines(lines2)
lines1 = self.lines[:] lines1 = self.lines[:]
nextline = None nextline = None
extralines = [] extralines = []
__tracebackhide__ = True __tracebackhide__ = True
wnick = len(match_nickname) + 1 wnick = len(match_nickname) + 1
started = False
for line in lines2: for line in lines2:
nomatchprinted = False nomatchprinted = False
while lines1: while lines1:
nextline = lines1.pop(0) nextline = lines1.pop(0)
if line == nextline: if line == nextline:
self._log("exact match:", repr(line)) self._log("exact match:", repr(line))
started = True
break break
elif match_func(nextline, line): elif match_func(nextline, line):
self._log("%s:" % match_nickname, repr(line)) self._log("%s:" % match_nickname, repr(line))
self._log( self._log(
"{:>{width}}".format("with:", width=wnick), repr(nextline) "{:>{width}}".format("with:", width=wnick), repr(nextline)
) )
started = True
break break
else: 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: if not nomatchprinted:
self._log( self._log(
"{:>{width}}".format("nomatch:", width=wnick), repr(line) "{:>{width}}".format("nomatch:", width=wnick), repr(line)
@ -1455,7 +1471,7 @@ class LineMatcher:
self._fail(msg) self._fail(msg)
self._log_output = [] 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``. """Ensure captured lines do not match the given pattern, using ``fnmatch.fnmatch``.
:param str pat: the pattern to match lines. :param str pat: the pattern to match lines.
@ -1463,15 +1479,19 @@ class LineMatcher:
__tracebackhide__ = True __tracebackhide__ = True
self._no_match_line(pat, fnmatch, "fnmatch") 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``. """Ensure captured lines do not match the given pattern, using ``re.match``.
:param str pat: the regular expression to match lines. :param str pat: the regular expression to match lines.
""" """
__tracebackhide__ = True __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`` """Ensure captured lines does not have a the given pattern, using ``fnmatch.fnmatch``
:param str pat: the pattern to match lines :param str pat: the pattern to match lines
@ -1492,8 +1512,12 @@ class LineMatcher:
self._log("{:>{width}}".format("and:", width=wnick), repr(line)) self._log("{:>{width}}".format("and:", width=wnick), repr(line))
self._log_output = [] self._log_output = []
def _fail(self, msg): def _fail(self, msg: str) -> None:
__tracebackhide__ = True __tracebackhide__ = True
log_text = self._log_text log_text = self._log_text
self._log_output = [] self._log_output = []
pytest.fail(log_text) pytest.fail(log_text)
def str(self) -> str:
"""Return the entire original text."""
return "\n".join(self.lines)

View File

@ -458,17 +458,26 @@ def test_testdir_run_timeout_expires(testdir) -> None:
def test_linematcher_with_nonlist() -> None: def test_linematcher_with_nonlist() -> None:
"""Test LineMatcher with regard to passing in a set (accidentally).""" """Test LineMatcher with regard to passing in a set (accidentally)."""
lm = LineMatcher([]) from _pytest._code.source import Source
with pytest.raises(AssertionError): lm = LineMatcher([])
lm.fnmatch_lines(set()) with pytest.raises(TypeError, match="invalid type for lines2: set"):
with pytest.raises(AssertionError): lm.fnmatch_lines(set()) # type: ignore[arg-type] # noqa: F821
lm.fnmatch_lines({}) 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([])
lm.fnmatch_lines(()) lm.fnmatch_lines(())
lm.fnmatch_lines("")
assert lm._getlines({}) == {} assert lm._getlines({}) == {} # type: ignore[arg-type,comparison-overlap] # noqa: F821
assert lm._getlines(set()) == set() 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: 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"]) @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": if function == "no_fnmatch_line":
good_pattern = "*.py OK*" good_pattern = "*.py OK*"
bad_pattern = "*X.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 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 = LineMatcher(["1", "2", "3"])
lm.fnmatch_lines(["1", "3"]) lm.fnmatch_lines(["1", "3"])
with pytest.raises(Failed) as e: with pytest.raises(Failed) as e: