pytester: LineMatcher: typing, docs, consecutive line matching (#6653)
This commit is contained in:
commit
39d9f7cff5
|
@ -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`.
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in New Issue