Skip to content

Commit

Permalink
pytester: LineMatcher: typing, docs, consecutive line matching (pytes…
Browse files Browse the repository at this point in the history
  • Loading branch information
blueyed authored Feb 4, 2020
2 parents 5a4c1b6 + 5256542 commit 39d9f7c
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 57 deletions.
1 change: 1 addition & 0 deletions changelog/6653.improvement.rst
Original file line number Diff line number Diff line change
@@ -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`.
118 changes: 71 additions & 47 deletions src/_pytest/pytester.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -1318,49 +1318,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:
Expand All @@ -1371,46 +1354,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
Expand All @@ -1420,28 +1424,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)
Expand All @@ -1455,23 +1471,27 @@ 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.
"""
__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
Expand All @@ -1492,8 +1512,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)
49 changes: 39 additions & 10 deletions testing/test_pytester.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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*"
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit 39d9f7c

Please sign in to comment.