Skip to content

Proposal: marker-driven loop/rerun protocol (deep hypothesis integration, pytest-repeat, retries) - #14779

Open
RonnyPfannschmidt wants to merge 1 commit into
pytest-dev:mainfrom
RonnyPfannschmidt:proposal/loop-protocol
Open

RonnyPfannschmidt wants to merge 1 commit into
pytest-dev:mainfrom
RonnyPfannschmidt:proposal/loop-protocol

Conversation

@RonnyPfannschmidt

Copy link
Copy Markdown
Member

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 with

  • fresh function-scoped fixtures per iteration (setup/teardown cycled through the normal SetupState/FixtureDef paths — no new reset API),
  • intermediate teardown of only the item frame (nextitem is item sentinel + a new SetupState.teardown_item_frame), with the final teardown honoring the real nextitem,
  • per-iteration 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,
  • deferrable reporting (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:

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=...) plus provides= give the engine everything it needs (generate + shrink + replay-minimal-with-pdb)?


🤖 Generated with Claude Code

@RonnyPfannschmidt
RonnyPfannschmidt requested a review from Zac-HD July 24, 2026 21:50
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the proposal/loop-protocol branch 2 times, most recently from ef0e85d to 7028175 Compare October 11, 2026 07:26
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>

@nicoddemus nicoddemus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting proposal, didn't read everything but did note some comments along the way.

Comment on lines +147 to +148
`LoopContext`, `IterationResult` and the `LoopController` typing protocol are
exported as `pytest.LoopContext` etc.; the implementation lives in a new

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs

report: Literal["live", "defer", "discard"] = "live",
) -> IterationResult: ...

def emit(self, result: IterationResult) -> None: ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs

when_failed: Literal["setup", "call", "teardown"] | None
duration: float

def emit(self) -> None: ... # convenience for LoopContext.emit

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def __call__(self, loop: LoopContext) -> None: ...
def __call__(self, context: LoopContext) -> None: ...

Comment on lines +134 to +135
excinfo: ExceptionInfo[BaseException] | None
when_failed: Literal["setup", "call", "teardown"] | None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given those two are tied together:

@dataclass(frozen=True)
class FailureDetails:
    when: Literal["setup", "call", "teardown"]
    excinfo: ExceptionInfo[BaseException]

Then:

Suggested change
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

@nicoddemus nicoddemus Oct 17, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@property
def config(self) -> pytest.Config: ... # Convenience for item.config.

Comment on lines +172 to +173
- `report="discard"` — never reportable; only counted in the loop summary
(hypothesis's 99 silent passing examples).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants