Skip to content

Commit 0c30a58

Browse files
committed
Show cache information and reset cache if test count changes
1 parent b6f1dad commit 0c30a58

File tree

3 files changed

+231
-45
lines changed

3 files changed

+231
-45
lines changed

changelog/13122.improvement.rst

+11-7
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
Improve the ``--stepwise``/``--sw`` flag to not forget the last failed test in case pytest is executed later without the flag.
1+
The ``--stepwise`` mode received a number of improvements:
22

3-
This enables the following workflow:
3+
* It no longer forgets the last failed test in case pytest is executed later without the flag.
44

5-
1. Execute pytest with ``--stepwise``, pytest then stops at the first failing test;
6-
2. Iteratively update the code and run the test in isolation, without the ``--stepwise`` flag (for example in an IDE), until it is fixed.
7-
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.
5+
This enables the following workflow:
86

9-
Previously, at step 3, pytest would start from the beginning, forgetting the previously failed test.
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.
1010

11-
Also added the new ``--stepwise-reset``/``--sw-reset``, allowing the user to explicitly reset the stepwise state and restart the workflow from the beginning.
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

+94-19
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from __future__ import annotations
22

3+
import dataclasses
4+
from datetime import datetime
5+
from typing import Any
6+
from typing import TYPE_CHECKING
7+
38
from _pytest import nodes
49
from _pytest.cacheprovider import Cache
510
from _pytest.config import Config
@@ -8,7 +13,12 @@
813
from _pytest.reports import TestReport
914

1015

11-
STEPWISE_CACHE_DIR = "cache/stepwise"
16+
if TYPE_CHECKING:
17+
from typing import ClassVar
18+
19+
from typing_extensions import Self
20+
21+
STEPWISE_CACHE_DIR = "cache/stepwise2"
1222

1323

1424
def pytest_addoption(parser: Parser) -> None:
@@ -61,41 +71,105 @@ def pytest_sessionfinish(session: Session) -> None:
6171
return
6272

6373

74+
@dataclasses.dataclass
75+
class StepwiseCacheInfo:
76+
# The nodeid of the last failed test.
77+
last_failed: str | None
78+
79+
# The number of tests in the last time --stepwise was run.
80+
# We use this information as a simple way to invalidate the cache information, avoiding
81+
# confusing behavior in case the cache is stale.
82+
last_test_count: int | None
83+
84+
# The date when the cache was last updated, for information purposes only.
85+
last_cache_date_str: str
86+
87+
_DATE_FORMAT: ClassVar[str] = "%Y-%m-%d %H:%M:%S"
88+
89+
@property
90+
def last_cache_date(self) -> datetime:
91+
return datetime.strptime(self.last_cache_date_str, self._DATE_FORMAT)
92+
93+
@classmethod
94+
def empty(cls) -> Self:
95+
return cls(
96+
last_failed=None,
97+
last_test_count=None,
98+
last_cache_date_str=datetime.now().strftime(cls._DATE_FORMAT),
99+
)
100+
101+
def update_date_to_now(self) -> None:
102+
self.last_cache_date_str = datetime.now().strftime(self._DATE_FORMAT)
103+
104+
64105
class StepwisePlugin:
65106
def __init__(self, config: Config) -> None:
66107
self.config = config
67108
self.session: Session | None = None
68-
self.report_status = ""
109+
self.report_status: list[str] = []
69110
assert config.cache is not None
70111
self.cache: Cache = config.cache
71-
self.lastfailed: str | None = self.cache.get(STEPWISE_CACHE_DIR, None)
72112
self.skip: bool = config.getoption("stepwise_skip")
73-
if config.getoption("stepwise_reset"):
74-
self.lastfailed = None
113+
self.reset: bool = config.getoption("stepwise_reset")
114+
self.cached_info = self._load_cached_info()
115+
116+
def _load_cached_info(self) -> StepwiseCacheInfo:
117+
cached_dict: dict[str, Any] | None = self.cache.get(STEPWISE_CACHE_DIR, None)
118+
if cached_dict:
119+
try:
120+
return StepwiseCacheInfo(
121+
cached_dict["last_failed"],
122+
cached_dict["last_test_count"],
123+
cached_dict["last_cache_date_str"],
124+
)
125+
except Exception as e:
126+
error = f"{type(e).__name__}: {e}"
127+
self.report_status.append(f"error reading cache, discarding ({error})")
128+
129+
# Cache not found or error during load, return a new cache.
130+
return StepwiseCacheInfo.empty()
75131

76132
def pytest_sessionstart(self, session: Session) -> None:
77133
self.session = session
78134

79135
def pytest_collection_modifyitems(
80136
self, config: Config, items: list[nodes.Item]
81137
) -> None:
82-
if not self.lastfailed:
83-
self.report_status = "no previously failed tests, not skipping."
138+
last_test_count = self.cached_info.last_test_count
139+
self.cached_info.last_test_count = len(items)
140+
141+
if self.reset:
142+
self.report_status.append("resetting state, not skipping.")
143+
self.cached_info.last_failed = None
144+
return
145+
146+
if not self.cached_info.last_failed:
147+
self.report_status.append("no previously failed tests, not skipping.")
148+
return
149+
150+
if last_test_count is not None and last_test_count != len(items):
151+
self.report_status.append(
152+
f"test count changed, not skipping (now {len(items)} tests, previously {last_test_count})."
153+
)
154+
self.cached_info.last_failed = None
84155
return
85156

86-
# check all item nodes until we find a match on last failed
157+
# Check all item nodes until we find a match on last failed.
87158
failed_index = None
88159
for index, item in enumerate(items):
89-
if item.nodeid == self.lastfailed:
160+
if item.nodeid == self.cached_info.last_failed:
90161
failed_index = index
91162
break
92163

93164
# If the previously failed test was not found among the test items,
94165
# do not skip any tests.
95166
if failed_index is None:
96-
self.report_status = "previously failed test not found, not skipping."
167+
self.report_status.append("previously failed test not found, not skipping.")
97168
else:
98-
self.report_status = f"skipping {failed_index} already passed items."
169+
self.report_status.append(
170+
f"skipping {failed_index} already passed items (cache from {self.cached_info.last_cache_date},"
171+
f" use --sw-reset to discard)."
172+
)
99173
deselected = items[:failed_index]
100174
del items[:failed_index]
101175
config.hook.pytest_deselected(items=deselected)
@@ -105,13 +179,13 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
105179
if self.skip:
106180
# Remove test from the failed ones (if it exists) and unset the skip option
107181
# to make sure the following tests will not be skipped.
108-
if report.nodeid == self.lastfailed:
109-
self.lastfailed = None
182+
if report.nodeid == self.cached_info.last_failed:
183+
self.cached_info.last_failed = None
110184

111185
self.skip = False
112186
else:
113187
# Mark test as the last failing and interrupt the test session.
114-
self.lastfailed = report.nodeid
188+
self.cached_info.last_failed = report.nodeid
115189
assert self.session is not None
116190
self.session.shouldstop = (
117191
"Test failed, continuing from this test next run."
@@ -121,17 +195,18 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
121195
# If the test was actually run and did pass.
122196
if report.when == "call":
123197
# Remove test from the failed ones, if exists.
124-
if report.nodeid == self.lastfailed:
125-
self.lastfailed = None
198+
if report.nodeid == self.cached_info.last_failed:
199+
self.cached_info.last_failed = None
126200

127-
def pytest_report_collectionfinish(self) -> str | None:
201+
def pytest_report_collectionfinish(self) -> list[str] | None:
128202
if self.config.get_verbosity() >= 0 and self.report_status:
129-
return f"stepwise: {self.report_status}"
203+
return [f"stepwise: {x}" for x in self.report_status]
130204
return None
131205

132206
def pytest_sessionfinish(self) -> None:
133207
if hasattr(self.config, "workerinput"):
134208
# Do not update cache if this process is a xdist worker to prevent
135209
# race conditions (#10641).
136210
return
137-
self.cache.set(STEPWISE_CACHE_DIR, self.lastfailed)
211+
self.cached_info.update_date_to_now()
212+
self.cache.set(STEPWISE_CACHE_DIR, dataclasses.asdict(self.cached_info))

0 commit comments

Comments
 (0)