Skip to content

Commit 2105aa5

Browse files
committed
LFPlugin: use sub-plugins to deselect during collection
Fixes #5301. Refactor/steps: - use var - harden test_lastfailed_usecase - harden test_failedfirst_order - revisit last_failed_paths - harden test_lastfailed_with_known_failures_not_being_selected
1 parent 781a730 commit 2105aa5

File tree

4 files changed

+128
-42
lines changed

4 files changed

+128
-42
lines changed

changelog/5301.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix ``--last-failed`` to collect new tests from files with known failures.

src/_pytest/cacheprovider.py

+79-27
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
import json
88
import os
99
from collections import OrderedDict
10+
from typing import Dict
11+
from typing import Generator
1012
from typing import List
13+
from typing import Optional
14+
from typing import Set
1115

1216
import attr
1317
import py
@@ -16,10 +20,12 @@
1620
from .pathlib import Path
1721
from .pathlib import resolve_from_str
1822
from .pathlib import rm_rf
23+
from .reports import CollectReport
1924
from _pytest import nodes
2025
from _pytest._io import TerminalWriter
2126
from _pytest.config import Config
2227
from _pytest.main import Session
28+
from _pytest.python import Module
2329

2430
README_CONTENT = """\
2531
# pytest cache directory #
@@ -161,42 +167,88 @@ def _ensure_supporting_files(self):
161167
cachedir_tag_path.write_bytes(CACHEDIR_TAG_CONTENT)
162168

163169

170+
class LFPluginCollWrapper:
171+
def __init__(self, lfplugin: "LFPlugin"):
172+
self.lfplugin = lfplugin
173+
self._collected_at_least_one_failure = False
174+
175+
@pytest.hookimpl(hookwrapper=True)
176+
def pytest_make_collect_report(self, collector) -> Generator:
177+
if isinstance(collector, Session):
178+
out = yield
179+
res = out.get_result() # type: CollectReport
180+
181+
# Sort any lf-paths to the beginning.
182+
lf_paths = self.lfplugin._last_failed_paths
183+
res.result = sorted(
184+
res.result, key=lambda x: 0 if Path(x.fspath) in lf_paths else 1,
185+
)
186+
out.force_result(res)
187+
return
188+
189+
elif isinstance(collector, Module):
190+
if Path(collector.fspath) in self.lfplugin._last_failed_paths:
191+
out = yield
192+
res = out.get_result()
193+
194+
filtered_result = [
195+
x for x in res.result if x.nodeid in self.lfplugin.lastfailed
196+
]
197+
if filtered_result:
198+
res.result = filtered_result
199+
out.force_result(res)
200+
201+
if not self._collected_at_least_one_failure:
202+
self.lfplugin.config.pluginmanager.register(
203+
LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip"
204+
)
205+
self._collected_at_least_one_failure = True
206+
return res
207+
yield
208+
209+
210+
class LFPluginCollSkipfiles:
211+
def __init__(self, lfplugin: "LFPlugin"):
212+
self.lfplugin = lfplugin
213+
214+
@pytest.hookimpl
215+
def pytest_make_collect_report(self, collector) -> Optional[CollectReport]:
216+
if isinstance(collector, Module):
217+
if Path(collector.fspath) not in self.lfplugin._last_failed_paths:
218+
self.lfplugin._skipped_files += 1
219+
220+
return CollectReport(
221+
collector.nodeid, "passed", longrepr=None, result=[]
222+
)
223+
return None
224+
225+
164226
class LFPlugin:
165227
""" Plugin which implements the --lf (run last-failing) option """
166228

167-
def __init__(self, config):
229+
def __init__(self, config: Config) -> None:
168230
self.config = config
169231
active_keys = "lf", "failedfirst"
170232
self.active = any(config.getoption(key) for key in active_keys)
171-
self.lastfailed = config.cache.get("cache/lastfailed", {})
233+
assert config.cache
234+
self.lastfailed = config.cache.get(
235+
"cache/lastfailed", {}
236+
) # type: Dict[str, bool]
172237
self._previously_failed_count = None
173238
self._report_status = None
174239
self._skipped_files = 0 # count skipped files during collection due to --lf
175240

176-
def last_failed_paths(self):
177-
"""Returns a set with all Paths()s of the previously failed nodeids (cached).
178-
"""
179-
try:
180-
return self._last_failed_paths
181-
except AttributeError:
182-
rootpath = Path(self.config.rootdir)
183-
result = {rootpath / nodeid.split("::")[0] for nodeid in self.lastfailed}
184-
result = {x for x in result if x.exists()}
185-
self._last_failed_paths = result
186-
return result
187-
188-
def pytest_ignore_collect(self, path):
189-
"""
190-
Ignore this file path if we are in --lf mode and it is not in the list of
191-
previously failed files.
192-
"""
193-
if self.active and self.config.getoption("lf") and path.isfile():
194-
last_failed_paths = self.last_failed_paths()
195-
if last_failed_paths:
196-
skip_it = Path(path) not in self.last_failed_paths()
197-
if skip_it:
198-
self._skipped_files += 1
199-
return skip_it
241+
if config.getoption("lf"):
242+
self._last_failed_paths = self.get_last_failed_paths()
243+
config.pluginmanager.register(
244+
LFPluginCollWrapper(self), "lfplugin-collwrapper"
245+
)
246+
247+
def get_last_failed_paths(self) -> Set[Path]:
248+
"""Returns a set with all Paths()s of the previously failed nodeids."""
249+
rootpath = Path(self.config.rootdir)
250+
result = {rootpath / nodeid.split("::")[0] for nodeid in self.lastfailed}
251+
return {x for x in result if x.exists()}
200252

201253
def pytest_report_collectionfinish(self):
202254
if self.active and self.config.getoption("verbose") >= 0:
@@ -380,7 +432,7 @@ def pytest_cmdline_main(config):
380432

381433

382434
@pytest.hookimpl(tryfirst=True)
383-
def pytest_configure(config):
435+
def pytest_configure(config: Config) -> None:
384436
config.cache = Cache.for_config(config)
385437
config.pluginmanager.register(LFPlugin(config), "lfplugin")
386438
config.pluginmanager.register(NFPlugin(config), "nfplugin")

src/_pytest/config/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,11 @@ def __init__(self, pluginmanager, *, invocation_params=None) -> None:
795795
kwargs=dict(parser=self._parser, pluginmanager=self.pluginmanager)
796796
)
797797

798+
if False: # TYPE_CHECKING
799+
from _pytest.cacheprovider import Cache
800+
801+
self.cache = None # type: Optional[Cache]
802+
798803
@property
799804
def invocation_dir(self):
800805
"""Backward compatibility"""

testing/test_cacheprovider.py

+43-15
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,13 @@ def test_3(): assert 0
265265
"""
266266
)
267267
result = testdir.runpytest(str(p), "--lf")
268-
result.stdout.fnmatch_lines(["*2 passed*1 desel*"])
268+
result.stdout.fnmatch_lines(
269+
[
270+
"collected 2 items",
271+
"run-last-failure: rerun previous 2 failures",
272+
"*= 2 passed in *",
273+
]
274+
)
269275
result = testdir.runpytest(str(p), "--lf")
270276
result.stdout.fnmatch_lines(
271277
[
@@ -295,8 +301,15 @@ def test_failedfirst_order(self, testdir):
295301
# Test order will be collection order; alphabetical
296302
result.stdout.fnmatch_lines(["test_a.py*", "test_b.py*"])
297303
result = testdir.runpytest("--ff")
298-
# Test order will be failing tests firs
299-
result.stdout.fnmatch_lines(["test_b.py*", "test_a.py*"])
304+
# Test order will be failing tests first
305+
result.stdout.fnmatch_lines(
306+
[
307+
"collected 2 items",
308+
"run-last-failure: rerun previous 1 failure first",
309+
"test_b.py*",
310+
"test_a.py*",
311+
]
312+
)
300313

301314
def test_lastfailed_failedfirst_order(self, testdir):
302315
testdir.makepyfile(
@@ -307,7 +320,7 @@ def test_lastfailed_failedfirst_order(self, testdir):
307320
# Test order will be collection order; alphabetical
308321
result.stdout.fnmatch_lines(["test_a.py*", "test_b.py*"])
309322
result = testdir.runpytest("--lf", "--ff")
310-
# Test order will be failing tests firs
323+
# Test order will be failing tests first
311324
result.stdout.fnmatch_lines(["test_b.py*"])
312325
result.stdout.no_fnmatch_line("*test_a.py*")
313326

@@ -332,7 +345,7 @@ def test_a2(): assert 1
332345
result = testdir.runpytest("--lf", p2)
333346
result.stdout.fnmatch_lines(["*1 passed*"])
334347
result = testdir.runpytest("--lf", p)
335-
result.stdout.fnmatch_lines(["*1 failed*1 desel*"])
348+
result.stdout.fnmatch_lines(["collected 1 item", "*= 1 failed in *"])
336349

337350
def test_lastfailed_usecase_splice(self, testdir, monkeypatch):
338351
monkeypatch.setattr("sys.dont_write_bytecode", True)
@@ -658,7 +671,13 @@ def test_bar_2(): pass
658671
assert self.get_cached_last_failed(testdir) == ["test_foo.py::test_foo_4"]
659672

660673
result = testdir.runpytest("--last-failed")
661-
result.stdout.fnmatch_lines(["*1 failed, 1 deselected*"])
674+
result.stdout.fnmatch_lines(
675+
[
676+
"collected 1 item",
677+
"run-last-failure: rerun previous 1 failure (skipped 1 file)",
678+
"*= 1 failed in *",
679+
]
680+
)
662681
assert self.get_cached_last_failed(testdir) == ["test_foo.py::test_foo_4"]
663682

664683
# 3. fix test_foo_4, run only test_foo.py
@@ -669,7 +688,13 @@ def test_foo_4(): pass
669688
"""
670689
)
671690
result = testdir.runpytest(test_foo, "--last-failed")
672-
result.stdout.fnmatch_lines(["*1 passed, 1 deselected*"])
691+
result.stdout.fnmatch_lines(
692+
[
693+
"collected 1 item",
694+
"run-last-failure: rerun previous 1 failure",
695+
"*= 1 passed in *",
696+
]
697+
)
673698
assert self.get_cached_last_failed(testdir) == []
674699

675700
result = testdir.runpytest("--last-failed")
@@ -759,9 +784,9 @@ def test_1(i):
759784
result = testdir.runpytest("--lf")
760785
result.stdout.fnmatch_lines(
761786
[
762-
"collected 5 items / 3 deselected / 2 selected",
787+
"collected 2 items",
763788
"run-last-failure: rerun previous 2 failures (skipped 1 file)",
764-
"*2 failed*3 deselected*",
789+
"*= 2 failed in *",
765790
]
766791
)
767792

@@ -776,9 +801,9 @@ def test_3(): pass
776801
result = testdir.runpytest("--lf")
777802
result.stdout.fnmatch_lines(
778803
[
779-
"collected 5 items / 3 deselected / 2 selected",
804+
"collected 2 items",
780805
"run-last-failure: rerun previous 2 failures (skipped 2 files)",
781-
"*2 failed*3 deselected*",
806+
"*= 2 failed in *",
782807
]
783808
)
784809

@@ -815,12 +840,15 @@ def test_lastfailed_with_known_failures_not_being_selected(self, testdir):
815840

816841
# Remove/rename test.
817842
testdir.makepyfile(**{"pkg1/test_1.py": """def test_renamed(): assert 0"""})
818-
result = testdir.runpytest("--lf")
843+
result = testdir.runpytest("--lf", "-rf")
819844
result.stdout.fnmatch_lines(
820845
[
821-
"collected 1 item",
822-
"run-last-failure: 1 known failures not in selected tests (skipped 1 file)",
823-
"* 1 failed in *",
846+
"collected 2 items",
847+
"run-last-failure: 1 known failures not in selected tests",
848+
"pkg1/test_1.py F *",
849+
"pkg1/test_2.py . *",
850+
"FAILED pkg1/test_1.py::test_renamed - assert 0",
851+
"* 1 failed, 1 passed in *",
824852
]
825853
)
826854

0 commit comments

Comments
 (0)