1 from typing import List

2 from typing import Optional

3 from typing import TYPE_CHECKING

4

5 import pytest

6 from _pytest import nodes

7 from _pytest.config import Config

8 from _pytest.config.argparsing import Parser

9 from _pytest.main import Session

10 from _pytest.reports import TestReport

11

12 if TYPE_CHECKING:

13 from _pytest.cacheprovider import Cache

14

15 STEPWISE_CACHE_DIR = "cache/stepwise"

16

17

18 def pytest_addoption(parser: Parser) -> None:

19 group = parser.getgroup("general")

20 group.addoption(

21 "--sw",

22 "--stepwise",

23 action="store_true",

24 default=False,

25 dest="stepwise",

26 help="Exit on test failure and continue from last failing test next time",

27 )

28 group.addoption(

29 "--sw-skip",

30 "--stepwise-skip",

31 action="store_true",

32 default=False,

33 dest="stepwise_skip",

34 help="Ignore the first failing test but stop on the next failing test. "

35 "Implicitly enables --stepwise.",

36 )

37

38

39@pytest.hookimpl

40 def pytest_configure(config: Config) -> None:

41 if config.option.stepwise_skip:

42 # allow --stepwise-skip to work on it's own merits.

43 config.option.stepwise = True

44 if config.getoption("stepwise"):

45 config.pluginmanager.register(StepwisePlugin(config), "stepwiseplugin")

46

47

48 def pytest_sessionfinish(session: Session) -> None:

49 if not session.config.getoption("stepwise"):

50 assert session.config.cache is not None

51 if hasattr(session.config, "workerinput"):

52 # Do not update cache if this process is a xdist worker to prevent

53 # race conditions (#10641).

54 return

55 # Clear the list of failing tests if the plugin is not active.

56 session.config.cache.set(STEPWISE_CACHE_DIR, [])

57

58

59 class StepwisePlugin:

60 def __init__(self, config: Config) -> None:

61 self.config = config

62 self.session: Optional[Session] = None

63 self.report_status = ""

64 assert config.cache is not None

65 self.cache: Cache = config.cache

66 self.lastfailed: Optional[str] = self.cache.get(STEPWISE_CACHE_DIR, None)

67 self.skip: bool = config.getoption("stepwise_skip")

68

69 def pytest_sessionstart(self, session: Session) -> None:

70 self.session = session

71

72 def pytest_collection_modifyitems(

73 self, config: Config, items: List[nodes.Item]

74 ) -> None:

75 if not self.lastfailed:

76 self.report_status = "no previously failed tests, not skipping."

77 return

78

79 # check all item nodes until we find a match on last failed

80 failed_index = None

81 for index, item in enumerate(items):

82 if item.nodeid == self.lastfailed:

83 failed_index = index

84 break

85

86 # If the previously failed test was not found among the test items,

87 # do not skip any tests.

88 if failed_index is None:

89 self.report_status = "previously failed test not found, not skipping."

90 else:

91 self.report_status = f"skipping {failed_index} already passed items."

92 deselected = items[:failed_index]

93 del items[:failed_index]

94 config.hook.pytest_deselected(items=deselected)

95

96 def pytest_runtest_logreport(self, report: TestReport) -> None:

97 if report.failed:

98 if self.skip:

99 # Remove test from the failed ones (if it exists) and unset the skip option

100 # to make sure the following tests will not be skipped.

101 if report.nodeid == self.lastfailed:

102 self.lastfailed = None

103

104 self.skip = False

105 else:

106 # Mark test as the last failing and interrupt the test session.

107 self.lastfailed = report.nodeid

108 assert self.session is not None

109 self.session.shouldstop = (

110 "Test failed, continuing from this test next run."

111 )

112

113 else:

114 # If the test was actually run and did pass.

115 if report.when == "call":

116 # Remove test from the failed ones, if exists.

117 if report.nodeid == self.lastfailed:

118 self.lastfailed = None

119

120 def pytest_report_collectionfinish(self) -> Optional[str]:

121 if self.config.getoption("verbose") >= 0 and self.report_status:

122 return f"stepwise: {self.report_status}"

123 return None

124

125 def pytest_sessionfinish(self) -> None:

126 if hasattr(self.config, "workerinput"):

127 # Do not update cache if this process is a xdist worker to prevent

128 # race conditions (#10641).

129 return

130 self.cache.set(STEPWISE_CACHE_DIR, self.lastfailed)