Repository navigation
Proposal: marker-driven loop/rerun protocol (deep hypothesis integration, pytest-repeat, retries) - #14779
Proposal: marker-driven loop/rerun protocol (deep hypothesis integration, pytest-repeat, retries)#14779RonnyPfannschmidt wants to merge 1 commit into
Conversation
f856c35 to
4a9be8f
Compare
4a9be8f to
d974ca3
Compare
ef0e85d to
7028175
Compare
A design document (no implementation) for a first-class test-loop protocol: a marker-carried loop controller drives repeated executions of one item with fresh function-scoped fixtures per iteration, per-iteration TestReports carrying structured loop keys (junitxml/xunit-usable), and deferrable reporting for deep hypothesis integration. Anchored to pytest-dev#916, pytest-dev#12596/pytest-dev#12597, pytest-dev#12939, pytest-dev#9699 and HypothesisWorks/hypothesis#377. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7028175 to
70b7ad4
Compare
nicoddemus
left a comment
There was a problem hiding this comment.
Interesting proposal, didn't read everything but did note some comments along the way.
| `LoopContext`, `IterationResult` and the `LoopController` typing protocol are | ||
| exported as `pytest.LoopContext` etc.; the implementation lives in a new |
There was a problem hiding this comment.
All these new classes are somewhat advanced usage, perhaps we should use a package to hold them, perhaps pytest.loop (pytest.loop.LoopContext)?
| item: Item # the item being looped | ||
| iteration_count: int # iterations executed so far | ||
|
|
||
| def run_iteration( |
| report: Literal["live", "defer", "discard"] = "live", | ||
| ) -> IterationResult: ... | ||
|
|
||
| def emit(self, result: IterationResult) -> None: ... |
| when_failed: Literal["setup", "call", "teardown"] | None | ||
| duration: float | ||
|
|
||
| def emit(self) -> None: ... # convenience for LoopContext.emit |
There was a problem hiding this comment.
This means IterationResult will have a an internal reference back to LoopContext?
I would remove this here from now, because it is easier for the controller to just call context.emit(result).
|
|
||
| ```python | ||
| class LoopController(Protocol): | ||
| def __call__(self, loop: LoopContext) -> None: ... |
There was a problem hiding this comment.
| def __call__(self, loop: LoopContext) -> None: ... | |
| def __call__(self, context: LoopContext) -> None: ... |
| excinfo: ExceptionInfo[BaseException] | None | ||
| when_failed: Literal["setup", "call", "teardown"] | None |
There was a problem hiding this comment.
Given those two are tied together:
@dataclass(frozen=True)
class FailureDetails:
when: Literal["setup", "call", "teardown"]
excinfo: ExceptionInfo[BaseException]Then:
| excinfo: ExceptionInfo[BaseException] | None | |
| when_failed: Literal["setup", "call", "teardown"] | None | |
| failure: FailureDetails | None |
| reports: tuple[TestReport, ...] # setup[, call], teardown | ||
| excinfo: ExceptionInfo[BaseException] | None | ||
| when_failed: Literal["setup", "call", "teardown"] | None | ||
| duration: float |
There was a problem hiding this comment.
Can't we get duration from reports? If so we should make this a read-only property.
| `LoopContext`, `IterationResult` and the `LoopController` typing protocol are | ||
| exported as `pytest.LoopContext` etc.; the implementation lives in a new | ||
| `src/_pytest/loop.py`. | ||
|
|
There was a problem hiding this comment.
We should include an example of how to implement LoopController that makes use of those classes here. We can use a simple example, like pytest-rerunfailures, with "repeat a test 3 times until it passes, otherwise reports the last failure".
pytest-rerunfailures itself reports a failed test as "R" (rerun), until it reaches the maximum failure count which then turns it into a proper error. For example, rerun=5, if a test fails twice and then passes on the 3rd time, it is reported as:
test_foo RERUN
test_foo RERUN
test_foo PASSED
How will this be handled in this proposal?
| ) -> IterationResult: ... | ||
|
|
||
| def emit(self, result: IterationResult) -> None: ... | ||
|
|
There was a problem hiding this comment.
| @property | |
| def config(self) -> pytest.Config: ... # Convenience for item.config. |
| - `report="discard"` — never reportable; only counted in the loop summary | ||
| (hypothesis's 99 silent passing examples). |
There was a problem hiding this comment.
Why having this option at all? I mean, the user can just ignore the report objects... the objective is to save memory? If so we can be explicit about the intent here.
What
A design document only — no implementation — proposing a first-class test-loop protocol: a marker-carried loop controller (
@pytest.mark.loop(controller)) drives repeated executions of one test item withSetupState/FixtureDefpaths — no new reset API),nextitem is itemsentinel + a newSetupState.teardown_item_frame), with the final teardown honoring the realnextitem,TestReports carrying a structured loop key (TestReport.loop), usable in junitxml (test_foo[param][key]testcases via(nodeid, workernode, loop_key)keying), the terminal, and machine-readable outputs,report="live" | "defer" | "discard") so an engine like hypothesis can run examples silently and report only the shrunk failure.Why
Anchored to the long-standing threads:
itemscope)The document includes controller sketches for hypothesis (generate/shrink/minimal-replay), pytest-repeat, and #12939-style retries, the downstream compatibility contract for report consumers, and a list of open questions.
cc @Zac-HD — would value your review of the controller API and the hypothesis sketch in particular: does
run_iteration(key, funcargs=..., report=...)plusprovides=give the engine everything it needs (generate + shrink + replay-minimal-with-pdb)?🤖 Generated with Claude Code