Skip to content

Commit 39d9f7c

Browse files
authored
pytester: LineMatcher: typing, docs, consecutive line matching (#6653)
2 parents 5a4c1b6 + 5256542 commit 39d9f7c

File tree

3 files changed

+111
-57
lines changed

3 files changed

+111
-57
lines changed

changelog/6653.improvement.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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`.

src/_pytest/pytester.py

+71-47
Original file line numberDiff line numberDiff line change
@@ -413,8 +413,8 @@ class RunResult:
413413
def __init__(
414414
self,
415415
ret: Union[int, ExitCode],
416-
outlines: Sequence[str],
417-
errlines: Sequence[str],
416+
outlines: List[str],
417+
errlines: List[str],
418418
duration: float,
419419
) -> None:
420420
try:
@@ -1318,49 +1318,32 @@ class LineMatcher:
13181318
13191319
The constructor takes a list of lines without their trailing newlines, i.e.
13201320
``text.splitlines()``.
1321-
13221321
"""
13231322

1324-
def __init__(self, lines):
1323+
def __init__(self, lines: List[str]) -> None:
13251324
self.lines = lines
1326-
self._log_output = []
1325+
self._log_output = [] # type: List[str]
13271326

1328-
def str(self):
1329-
"""Return the entire original text."""
1330-
return "\n".join(self.lines)
1331-
1332-
def _getlines(self, lines2):
1327+
def _getlines(self, lines2: Union[str, Sequence[str], Source]) -> Sequence[str]:
13331328
if isinstance(lines2, str):
13341329
lines2 = Source(lines2)
13351330
if isinstance(lines2, Source):
13361331
lines2 = lines2.strip().lines
13371332
return lines2
13381333

1339-
def fnmatch_lines_random(self, lines2):
1340-
"""Check lines exist in the output using in any order.
1341-
1342-
Lines are checked using ``fnmatch.fnmatch``. The argument is a list of
1343-
lines which have to occur in the output, in any order.
1344-
1334+
def fnmatch_lines_random(self, lines2: Sequence[str]) -> None:
1335+
"""Check lines exist in the output in any order (using :func:`python:fnmatch.fnmatch`).
13451336
"""
13461337
self._match_lines_random(lines2, fnmatch)
13471338

1348-
def re_match_lines_random(self, lines2):
1349-
"""Check lines exist in the output using ``re.match``, in any order.
1350-
1351-
The argument is a list of lines which have to occur in the output, in
1352-
any order.
1353-
1339+
def re_match_lines_random(self, lines2: Sequence[str]) -> None:
1340+
"""Check lines exist in the output in any order (using :func:`python:re.match`).
13541341
"""
1355-
self._match_lines_random(lines2, lambda name, pat: re.match(pat, name))
1356-
1357-
def _match_lines_random(self, lines2, match_func):
1358-
"""Check lines exist in the output.
1359-
1360-
The argument is a list of lines which have to occur in the output, in
1361-
any order. Each line can contain glob whildcards.
1342+
self._match_lines_random(lines2, lambda name, pat: bool(re.match(pat, name)))
13621343

1363-
"""
1344+
def _match_lines_random(
1345+
self, lines2: Sequence[str], match_func: Callable[[str, str], bool]
1346+
) -> None:
13641347
lines2 = self._getlines(lines2)
13651348
for line in lines2:
13661349
for x in self.lines:
@@ -1371,46 +1354,67 @@ def _match_lines_random(self, lines2, match_func):
13711354
self._log("line %r not found in output" % line)
13721355
raise ValueError(self._log_text)
13731356

1374-
def get_lines_after(self, fnline):
1357+
def get_lines_after(self, fnline: str) -> Sequence[str]:
13751358
"""Return all lines following the given line in the text.
13761359
13771360
The given line can contain glob wildcards.
1378-
13791361
"""
13801362
for i, line in enumerate(self.lines):
13811363
if fnline == line or fnmatch(line, fnline):
13821364
return self.lines[i + 1 :]
13831365
raise ValueError("line %r not found in output" % fnline)
13841366

1385-
def _log(self, *args):
1367+
def _log(self, *args) -> None:
13861368
self._log_output.append(" ".join(str(x) for x in args))
13871369

13881370
@property
1389-
def _log_text(self):
1371+
def _log_text(self) -> str:
13901372
return "\n".join(self._log_output)
13911373

1392-
def fnmatch_lines(self, lines2):
1393-
"""Search captured text for matching lines using ``fnmatch.fnmatch``.
1374+
def fnmatch_lines(
1375+
self, lines2: Sequence[str], *, consecutive: bool = False
1376+
) -> None:
1377+
"""Check lines exist in the output (using :func:`python:fnmatch.fnmatch`).
13941378
13951379
The argument is a list of lines which have to match and can use glob
13961380
wildcards. If they do not match a pytest.fail() is called. The
13971381
matches and non-matches are also shown as part of the error message.
1382+
1383+
:param lines2: string patterns to match.
1384+
:param consecutive: match lines consecutive?
13981385
"""
13991386
__tracebackhide__ = True
1400-
self._match_lines(lines2, fnmatch, "fnmatch")
1387+
self._match_lines(lines2, fnmatch, "fnmatch", consecutive=consecutive)
14011388

1402-
def re_match_lines(self, lines2):
1403-
"""Search captured text for matching lines using ``re.match``.
1389+
def re_match_lines(
1390+
self, lines2: Sequence[str], *, consecutive: bool = False
1391+
) -> None:
1392+
"""Check lines exist in the output (using :func:`python:re.match`).
14041393
14051394
The argument is a list of lines which have to match using ``re.match``.
14061395
If they do not match a pytest.fail() is called.
14071396
14081397
The matches and non-matches are also shown as part of the error message.
1398+
1399+
:param lines2: string patterns to match.
1400+
:param consecutive: match lines consecutively?
14091401
"""
14101402
__tracebackhide__ = True
1411-
self._match_lines(lines2, lambda name, pat: re.match(pat, name), "re.match")
1403+
self._match_lines(
1404+
lines2,
1405+
lambda name, pat: bool(re.match(pat, name)),
1406+
"re.match",
1407+
consecutive=consecutive,
1408+
)
14121409

1413-
def _match_lines(self, lines2, match_func, match_nickname):
1410+
def _match_lines(
1411+
self,
1412+
lines2: Sequence[str],
1413+
match_func: Callable[[str, str], bool],
1414+
match_nickname: str,
1415+
*,
1416+
consecutive: bool = False
1417+
) -> None:
14141418
"""Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``.
14151419
14161420
:param list[str] lines2: list of string patterns to match. The actual
@@ -1420,28 +1424,40 @@ def _match_lines(self, lines2, match_func, match_nickname):
14201424
pattern
14211425
:param str match_nickname: the nickname for the match function that
14221426
will be logged to stdout when a match occurs
1427+
:param consecutive: match lines consecutively?
14231428
"""
1424-
assert isinstance(lines2, collections.abc.Sequence)
1429+
if not isinstance(lines2, collections.abc.Sequence):
1430+
raise TypeError("invalid type for lines2: {}".format(type(lines2).__name__))
14251431
lines2 = self._getlines(lines2)
14261432
lines1 = self.lines[:]
14271433
nextline = None
14281434
extralines = []
14291435
__tracebackhide__ = True
14301436
wnick = len(match_nickname) + 1
1437+
started = False
14311438
for line in lines2:
14321439
nomatchprinted = False
14331440
while lines1:
14341441
nextline = lines1.pop(0)
14351442
if line == nextline:
14361443
self._log("exact match:", repr(line))
1444+
started = True
14371445
break
14381446
elif match_func(nextline, line):
14391447
self._log("%s:" % match_nickname, repr(line))
14401448
self._log(
14411449
"{:>{width}}".format("with:", width=wnick), repr(nextline)
14421450
)
1451+
started = True
14431452
break
14441453
else:
1454+
if consecutive and started:
1455+
msg = "no consecutive match: {!r}".format(line)
1456+
self._log(msg)
1457+
self._log(
1458+
"{:>{width}}".format("with:", width=wnick), repr(nextline)
1459+
)
1460+
self._fail(msg)
14451461
if not nomatchprinted:
14461462
self._log(
14471463
"{:>{width}}".format("nomatch:", width=wnick), repr(line)
@@ -1455,23 +1471,27 @@ def _match_lines(self, lines2, match_func, match_nickname):
14551471
self._fail(msg)
14561472
self._log_output = []
14571473

1458-
def no_fnmatch_line(self, pat):
1474+
def no_fnmatch_line(self, pat: str) -> None:
14591475
"""Ensure captured lines do not match the given pattern, using ``fnmatch.fnmatch``.
14601476
14611477
:param str pat: the pattern to match lines.
14621478
"""
14631479
__tracebackhide__ = True
14641480
self._no_match_line(pat, fnmatch, "fnmatch")
14651481

1466-
def no_re_match_line(self, pat):
1482+
def no_re_match_line(self, pat: str) -> None:
14671483
"""Ensure captured lines do not match the given pattern, using ``re.match``.
14681484
14691485
:param str pat: the regular expression to match lines.
14701486
"""
14711487
__tracebackhide__ = True
1472-
self._no_match_line(pat, lambda name, pat: re.match(pat, name), "re.match")
1488+
self._no_match_line(
1489+
pat, lambda name, pat: bool(re.match(pat, name)), "re.match"
1490+
)
14731491

1474-
def _no_match_line(self, pat, match_func, match_nickname):
1492+
def _no_match_line(
1493+
self, pat: str, match_func: Callable[[str, str], bool], match_nickname: str
1494+
) -> None:
14751495
"""Ensure captured lines does not have a the given pattern, using ``fnmatch.fnmatch``
14761496
14771497
:param str pat: the pattern to match lines
@@ -1492,8 +1512,12 @@ def _no_match_line(self, pat, match_func, match_nickname):
14921512
self._log("{:>{width}}".format("and:", width=wnick), repr(line))
14931513
self._log_output = []
14941514

1495-
def _fail(self, msg):
1515+
def _fail(self, msg: str) -> None:
14961516
__tracebackhide__ = True
14971517
log_text = self._log_text
14981518
self._log_output = []
14991519
pytest.fail(log_text)
1520+
1521+
def str(self) -> str:
1522+
"""Return the entire original text."""
1523+
return "\n".join(self.lines)

testing/test_pytester.py

+39-10
Original file line numberDiff line numberDiff line change
@@ -458,17 +458,26 @@ def test_timeout():
458458

459459
def test_linematcher_with_nonlist() -> None:
460460
"""Test LineMatcher with regard to passing in a set (accidentally)."""
461-
lm = LineMatcher([])
461+
from _pytest._code.source import Source
462462

463-
with pytest.raises(AssertionError):
464-
lm.fnmatch_lines(set())
465-
with pytest.raises(AssertionError):
466-
lm.fnmatch_lines({})
463+
lm = LineMatcher([])
464+
with pytest.raises(TypeError, match="invalid type for lines2: set"):
465+
lm.fnmatch_lines(set()) # type: ignore[arg-type] # noqa: F821
466+
with pytest.raises(TypeError, match="invalid type for lines2: dict"):
467+
lm.fnmatch_lines({}) # type: ignore[arg-type] # noqa: F821
468+
with pytest.raises(TypeError, match="invalid type for lines2: set"):
469+
lm.re_match_lines(set()) # type: ignore[arg-type] # noqa: F821
470+
with pytest.raises(TypeError, match="invalid type for lines2: dict"):
471+
lm.re_match_lines({}) # type: ignore[arg-type] # noqa: F821
472+
with pytest.raises(TypeError, match="invalid type for lines2: Source"):
473+
lm.fnmatch_lines(Source()) # type: ignore[arg-type] # noqa: F821
467474
lm.fnmatch_lines([])
468475
lm.fnmatch_lines(())
469-
470-
assert lm._getlines({}) == {}
471-
assert lm._getlines(set()) == set()
476+
lm.fnmatch_lines("")
477+
assert lm._getlines({}) == {} # type: ignore[arg-type,comparison-overlap] # noqa: F821
478+
assert lm._getlines(set()) == set() # type: ignore[arg-type,comparison-overlap] # noqa: F821
479+
assert lm._getlines(Source()) == []
480+
assert lm._getlines(Source("pass\npass")) == ["pass", "pass"]
472481

473482

474483
def test_linematcher_match_failure() -> None:
@@ -499,8 +508,28 @@ def test_linematcher_match_failure() -> None:
499508
]
500509

501510

511+
def test_linematcher_consecutive():
512+
lm = LineMatcher(["1", "", "2"])
513+
with pytest.raises(pytest.fail.Exception) as excinfo:
514+
lm.fnmatch_lines(["1", "2"], consecutive=True)
515+
assert str(excinfo.value).splitlines() == [
516+
"exact match: '1'",
517+
"no consecutive match: '2'",
518+
" with: ''",
519+
]
520+
521+
lm.re_match_lines(["1", r"\d?", "2"], consecutive=True)
522+
with pytest.raises(pytest.fail.Exception) as excinfo:
523+
lm.re_match_lines(["1", r"\d", "2"], consecutive=True)
524+
assert str(excinfo.value).splitlines() == [
525+
"exact match: '1'",
526+
r"no consecutive match: '\\d'",
527+
" with: ''",
528+
]
529+
530+
502531
@pytest.mark.parametrize("function", ["no_fnmatch_line", "no_re_match_line"])
503-
def test_no_matching(function) -> None:
532+
def test_linematcher_no_matching(function) -> None:
504533
if function == "no_fnmatch_line":
505534
good_pattern = "*.py OK*"
506535
bad_pattern = "*X.py OK*"
@@ -548,7 +577,7 @@ def test_no_matching(function) -> None:
548577
func(bad_pattern) # bad pattern does not match any line: passes
549578

550579

551-
def test_no_matching_after_match() -> None:
580+
def test_linematcher_no_matching_after_match() -> None:
552581
lm = LineMatcher(["1", "2", "3"])
553582
lm.fnmatch_lines(["1", "3"])
554583
with pytest.raises(Failed) as e:

0 commit comments

Comments
 (0)