Skip to content

Commit fc56ae3

Browse files
authored
Merge pull request #13122 from nicoddemus/stepwise-clear-cache
Improve stepwise to not forget failed tests
2 parents 517b006 + 2fcaa79 commit fc56ae3

File tree

4 files changed

+309
-25
lines changed

4 files changed

+309
-25
lines changed

changelog/13122.improvement.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
The ``--stepwise`` mode received a number of improvements:
2+
3+
* It no longer forgets the last failed test in case pytest is executed later without the flag.
4+
5+
This enables the following workflow:
6+
7+
1. Execute pytest with ``--stepwise``, pytest then stops at the first failing test;
8+
2. Iteratively update the code and run the test in isolation, without the ``--stepwise`` flag (for example in an IDE), until it is fixed.
9+
3. Execute pytest with ``--stepwise`` again and pytest will continue from the previously failed test, and if it passes, continue on to the next tests.
10+
11+
Previously, at step 3, pytest would start from the beginning, forgetting the previously failed test.
12+
13+
This change however might cause issues if the ``--stepwise`` mode is used far apart in time, as the state might get stale, so the internal state will be reset automatically in case the test suite changes (for now only the number of tests are considered for this, we might change/improve this on the future).
14+
15+
* New ``--stepwise-reset``/``--sw-reset`` flag, allowing the user to explicitly reset the stepwise state and restart the workflow from the beginning.

src/_pytest/stepwise.py

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
from __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+
39
from _pytest import nodes
410
from _pytest.cacheprovider import Cache
511
from _pytest.config import Config
@@ -8,6 +14,9 @@
814
from _pytest.reports import TestReport
915

1016

17+
if TYPE_CHECKING:
18+
from typing_extensions import Self
19+
1120
STEPWISE_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

3553
def 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

5499
class 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))

testing/test_cacheprovider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def test_cache_failure_warns(
104104
pytester.makepyfile("def test_error(): raise Exception")
105105
result = pytester.runpytest()
106106
assert result.ret == 1
107-
# warnings from nodeids, lastfailed, and stepwise
107+
# warnings from nodeids and lastfailed
108108
result.stdout.fnmatch_lines(
109109
[
110110
# Validate location/stacklevel of warning from cacheprovider.
@@ -113,7 +113,7 @@ def test_cache_failure_warns(
113113
" */cacheprovider.py:*: PytestCacheWarning: could not create cache path "
114114
f"{unwritable_cache_dir}/v/cache/nodeids: *",
115115
' config.cache.set("cache/nodeids", sorted(self.cached_nodeids))',
116-
"*1 failed, 3 warnings in*",
116+
"*1 failed, 2 warnings in*",
117117
]
118118
)
119119

0 commit comments

Comments
 (0)