11from __future__ import annotations
22
3+ import dataclasses
4+ from datetime import datetime
5+ from datetime import timedelta
6+ from typing import Any
7+ from typing import TYPE_CHECKING
8+
39from _pytest import nodes
410from _pytest .cacheprovider import Cache
511from _pytest .config import Config
814from _pytest .reports import TestReport
915
1016
17+ if TYPE_CHECKING :
18+ from typing_extensions import Self
19+
1120STEPWISE_CACHE_DIR = "cache/stepwise"
1221
1322
@@ -30,11 +39,20 @@ def pytest_addoption(parser: Parser) -> None:
3039 help = "Ignore the first failing test but stop on the next failing test. "
3140 "Implicitly enables --stepwise." ,
3241 )
42+ group .addoption (
43+ "--sw-reset" ,
44+ "--stepwise-reset" ,
45+ action = "store_true" ,
46+ default = False ,
47+ dest = "stepwise_reset" ,
48+ help = "Resets stepwise state, restarting the stepwise workflow. "
49+ "Implicitly enables --stepwise." ,
50+ )
3351
3452
3553def pytest_configure (config : Config ) -> None :
36- if config . option . stepwise_skip :
37- # allow --stepwise-skip to work on its own merits.
54+ # --stepwise-skip/--stepwise-reset implies stepwise.
55+ if config . option . stepwise_skip or config . option . stepwise_reset :
3856 config .option .stepwise = True
3957 if config .getoption ("stepwise" ):
4058 config .pluginmanager .register (StepwisePlugin (config ), "stepwiseplugin" )
@@ -47,43 +65,108 @@ def pytest_sessionfinish(session: Session) -> None:
4765 # Do not update cache if this process is a xdist worker to prevent
4866 # race conditions (#10641).
4967 return
50- # Clear the list of failing tests if the plugin is not active.
51- session .config .cache .set (STEPWISE_CACHE_DIR , [])
68+
69+
70+ @dataclasses .dataclass
71+ class StepwiseCacheInfo :
72+ # The nodeid of the last failed test.
73+ last_failed : str | None
74+
75+ # The number of tests in the last time --stepwise was run.
76+ # We use this information as a simple way to invalidate the cache information, avoiding
77+ # confusing behavior in case the cache is stale.
78+ last_test_count : int | None
79+
80+ # The date when the cache was last updated, for information purposes only.
81+ last_cache_date_str : str
82+
83+ @property
84+ def last_cache_date (self ) -> datetime :
85+ return datetime .fromisoformat (self .last_cache_date_str )
86+
87+ @classmethod
88+ def empty (cls ) -> Self :
89+ return cls (
90+ last_failed = None ,
91+ last_test_count = None ,
92+ last_cache_date_str = datetime .now ().isoformat (),
93+ )
94+
95+ def update_date_to_now (self ) -> None :
96+ self .last_cache_date_str = datetime .now ().isoformat ()
5297
5398
5499class StepwisePlugin :
55100 def __init__ (self , config : Config ) -> None :
56101 self .config = config
57102 self .session : Session | None = None
58- self .report_status = ""
103+ self .report_status : list [ str ] = []
59104 assert config .cache is not None
60105 self .cache : Cache = config .cache
61- self .lastfailed : str | None = self .cache .get (STEPWISE_CACHE_DIR , None )
62106 self .skip : bool = config .getoption ("stepwise_skip" )
107+ self .reset : bool = config .getoption ("stepwise_reset" )
108+ self .cached_info = self ._load_cached_info ()
109+
110+ def _load_cached_info (self ) -> StepwiseCacheInfo :
111+ cached_dict : dict [str , Any ] | None = self .cache .get (STEPWISE_CACHE_DIR , None )
112+ if cached_dict :
113+ try :
114+ return StepwiseCacheInfo (
115+ cached_dict ["last_failed" ],
116+ cached_dict ["last_test_count" ],
117+ cached_dict ["last_cache_date_str" ],
118+ )
119+ except (KeyError , TypeError ) as e :
120+ error = f"{ type (e ).__name__ } : { e } "
121+ self .report_status .append (f"error reading cache, discarding ({ error } )" )
122+
123+ # Cache not found or error during load, return a new cache.
124+ return StepwiseCacheInfo .empty ()
63125
64126 def pytest_sessionstart (self , session : Session ) -> None :
65127 self .session = session
66128
67129 def pytest_collection_modifyitems (
68130 self , config : Config , items : list [nodes .Item ]
69131 ) -> None :
70- if not self .lastfailed :
71- self .report_status = "no previously failed tests, not skipping."
132+ last_test_count = self .cached_info .last_test_count
133+ self .cached_info .last_test_count = len (items )
134+
135+ if self .reset :
136+ self .report_status .append ("resetting state, not skipping." )
137+ self .cached_info .last_failed = None
138+ return
139+
140+ if not self .cached_info .last_failed :
141+ self .report_status .append ("no previously failed tests, not skipping." )
142+ return
143+
144+ if last_test_count is not None and last_test_count != len (items ):
145+ self .report_status .append (
146+ f"test count changed, not skipping (now { len (items )} tests, previously { last_test_count } )."
147+ )
148+ self .cached_info .last_failed = None
72149 return
73150
74- # check all item nodes until we find a match on last failed
151+ # Check all item nodes until we find a match on last failed.
75152 failed_index = None
76153 for index , item in enumerate (items ):
77- if item .nodeid == self .lastfailed :
154+ if item .nodeid == self .cached_info . last_failed :
78155 failed_index = index
79156 break
80157
81158 # If the previously failed test was not found among the test items,
82159 # do not skip any tests.
83160 if failed_index is None :
84- self .report_status = "previously failed test not found, not skipping."
161+ self .report_status . append ( "previously failed test not found, not skipping." )
85162 else :
86- self .report_status = f"skipping { failed_index } already passed items."
163+ cache_age = datetime .now () - self .cached_info .last_cache_date
164+ # Round up to avoid showing microseconds.
165+ cache_age = timedelta (seconds = int (cache_age .total_seconds ()))
166+ self .report_status .append (
167+ f"skipping { failed_index } already passed items (cache from { cache_age } ago,"
168+ f" use --sw-reset to discard)."
169+ )
87170 deselected = items [:failed_index ]
88171 del items [:failed_index ]
89172 config .hook .pytest_deselected (items = deselected )
@@ -93,13 +176,13 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
93176 if self .skip :
94177 # Remove test from the failed ones (if it exists) and unset the skip option
95178 # to make sure the following tests will not be skipped.
96- if report .nodeid == self .lastfailed :
97- self .lastfailed = None
179+ if report .nodeid == self .cached_info . last_failed :
180+ self .cached_info . last_failed = None
98181
99182 self .skip = False
100183 else :
101184 # Mark test as the last failing and interrupt the test session.
102- self .lastfailed = report .nodeid
185+ self .cached_info . last_failed = report .nodeid
103186 assert self .session is not None
104187 self .session .shouldstop = (
105188 "Test failed, continuing from this test next run."
@@ -109,17 +192,18 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
109192 # If the test was actually run and did pass.
110193 if report .when == "call" :
111194 # Remove test from the failed ones, if exists.
112- if report .nodeid == self .lastfailed :
113- self .lastfailed = None
195+ if report .nodeid == self .cached_info . last_failed :
196+ self .cached_info . last_failed = None
114197
115- def pytest_report_collectionfinish (self ) -> str | None :
198+ def pytest_report_collectionfinish (self ) -> list [ str ] | None :
116199 if self .config .get_verbosity () >= 0 and self .report_status :
117- return f"stepwise: { self .report_status } "
200+ return [ f"stepwise: { x } " for x in self .report_status ]
118201 return None
119202
120203 def pytest_sessionfinish (self ) -> None :
121204 if hasattr (self .config , "workerinput" ):
122205 # Do not update cache if this process is a xdist worker to prevent
123206 # race conditions (#10641).
124207 return
125- self .cache .set (STEPWISE_CACHE_DIR , self .lastfailed )
208+ self .cached_info .update_date_to_now ()
209+ self .cache .set (STEPWISE_CACHE_DIR , dataclasses .asdict (self .cached_info ))
0 commit comments