From 662beae6dec0b5b750a3c2730c934a5211e972bf Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 22 Oct 2026 17:41:54 -0400 Subject: [PATCH 1/2] gh-107862: Add example.xfail() and example.via() to the Hypothesis stubs Hypothesis lets a test pin a known-failing input with `@example(...).xfail(reason=..., raises=...)`, which is the natural way to record a bug found by a property test without taking the input out of the strategy or writing a separate expectedFailure test. The stub `example` is a plain decorator function, so any test using `.xfail()` or `.via()` failed to import when Hypothesis is not installed. This turns the stub into a class with those two methods. An expected failure is strict, as in Hypothesis: when the examples are replayed the test must raise one of the `raises` exceptions for that input, and an unexpected pass fails the test. `.via()` is a no-op, as it is upstream. --- .../support/_hypothesis_stubs/__init__.py | 68 +++++++++++++++---- ...-10-22-10-00-00.gh-issue-107862.vX7pLm.rst | 3 + 2 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 Misc/NEWS.d/next/Tests/2026-10-22-10-00-00.gh-issue-107862.vX7pLm.rst diff --git a/Lib/test/support/_hypothesis_stubs/__init__.py b/Lib/test/support/_hypothesis_stubs/__init__.py index e3cb54aad736d2..94b6f7eaf06a44 100644 --- a/Lib/test/support/_hypothesis_stubs/__init__.py +++ b/Lib/test/support/_hypothesis_stubs/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, replace from enum import Enum import functools import unittest @@ -18,21 +19,52 @@ from . import stateful, strategies +@dataclass(frozen=True) +class _Example: + args: tuple + kwargs: dict + # Set by example.xfail(): the exception types the example must raise. + raises: type[BaseException] | tuple[type[BaseException], ...] | None = None + reason: str = "" + + def __repr__(self): + bits = [repr(a) for a in self.args] + bits += [f"{k}={v!r}" for k, v in self.kwargs.items()] + return f"@example({', '.join(bits)})" + + +def _run_example(f, self, ex): + if ex.raises is None: + f(self, *ex.args, **ex.kwargs) + return + try: + f(self, *ex.args, **ex.kwargs) + except Exception as exc: + if not isinstance(exc, ex.raises): + raise + return + reason = f" because {ex.reason}" if ex.reason else "" + raise AssertionError( + f"Expected an exception from {ex!r}{reason}, " + "but no exception was raised" + ) + + def given(*_args, **_kwargs): def decorator(f): if examples := getattr(f, "_examples", []): @functools.wraps(f) def test_function(self): - for example_args, example_kwargs in examples: - if len(example_args) < 2: - subtest_args = example_args + for ex in examples: + if len(ex.args) < 2: + subtest_args = ex.args else: # subTest takes up to one positional argument. # When there are more, display them as a tuple - subtest_args = [example_args] - with self.subTest(*subtest_args, **example_kwargs): - f(self, *example_args, **example_kwargs) + subtest_args = [ex.args] + with self.subTest(*subtest_args, **ex.kwargs): + _run_example(f, self, ex) else: # If we have found no examples, we must skip the test. If @example @@ -49,16 +81,18 @@ def test_function(self): return decorator -def example(*args, **kwargs): - if bool(args) == bool(kwargs): - raise ValueError("Must specify exactly one of *args or **kwargs") +class example: + def __init__(self, *args, **kwargs): + if bool(args) == bool(kwargs): + raise ValueError("Must specify exactly one of *args or **kwargs") + self._example = _Example(args, kwargs) - def decorator(f): + def __call__(self, f): base_func = getattr(f, "__wrapped__", f) if not hasattr(base_func, "_examples"): base_func._examples = [] - base_func._examples.append((args, kwargs)) + base_func._examples.append(self._example) if getattr(f, "_given", False): # If the given decorator is below all the example decorators, @@ -68,7 +102,17 @@ def decorator(f): return f - return decorator + def xfail(self, condition=True, *, reason="", raises=BaseException): + # Like real Hypothesis, an expected failure is strict: the example + # must raise one of the given exceptions, or the test fails. + if condition: + self._example = replace(self._example, raises=raises, + reason=reason) + return self + + def via(self, whence, /): + # A label for tooling; it does not change what runs. + return self def assume(condition): diff --git a/Misc/NEWS.d/next/Tests/2026-10-22-10-00-00.gh-issue-107862.vX7pLm.rst b/Misc/NEWS.d/next/Tests/2026-10-22-10-00-00.gh-issue-107862.vX7pLm.rst new file mode 100644 index 00000000000000..6c583e75c39c56 --- /dev/null +++ b/Misc/NEWS.d/next/Tests/2026-10-22-10-00-00.gh-issue-107862.vX7pLm.rst @@ -0,0 +1,3 @@ +Add ``example.xfail()`` and ``example.via()`` to the Hypothesis stubs used +when Hypothesis is not installed, so that property tests can mark an explicit +example as an expected failure in both modes. From 18b3535b6a78a92dc0bcc277dae034b78491772c Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Fri, 23 Oct 2026 14:11:57 -0400 Subject: [PATCH 2/2] Document what the example stubs stand in for The example class and its xfail() and via() methods had only comments, so it was not clear from the stub what via() does upstream (nothing at run time; it labels an example's provenance for tooling) or that an expected failure is strict. Docstrings now say what each one does in Hypothesis and how the stub replays it. --- .../support/_hypothesis_stubs/__init__.py | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/Lib/test/support/_hypothesis_stubs/__init__.py b/Lib/test/support/_hypothesis_stubs/__init__.py index 94b6f7eaf06a44..8baeeac4de02b1 100644 --- a/Lib/test/support/_hypothesis_stubs/__init__.py +++ b/Lib/test/support/_hypothesis_stubs/__init__.py @@ -82,6 +82,14 @@ def test_function(self): class example: + """Stub for ``hypothesis.example``. + + Upstream, ``@example(...)`` adds an explicit input that Hypothesis + always runs before generating any, with the same positional-or-keyword + rules as ``@given``. Here the explicit examples are all that runs: + ``given`` replays each one in a ``subTest``. + """ + def __init__(self, *args, **kwargs): if bool(args) == bool(kwargs): raise ValueError("Must specify exactly one of *args or **kwargs") @@ -103,15 +111,28 @@ def __call__(self, f): return f def xfail(self, condition=True, *, reason="", raises=BaseException): - # Like real Hypothesis, an expected failure is strict: the example - # must raise one of the given exceptions, or the test fails. + """Mark this example as an expected failure, as upstream does. + + Upstream, ``@example(...).xfail()`` means the example must raise + one of the ``raises`` exceptions (any exception by default), like + ``pytest.mark.xfail(strict=True)``: a different exception + propagates, and an unexpected pass fails the test, with ``reason`` + in the message. ``condition=False`` makes it an ordinary example. + The stub replays it the same way. + """ if condition: self._example = replace(self._example, raises=raises, reason=reason) return self def via(self, whence, /): - # A label for tooling; it does not change what runs. + """Accept and ignore an example's provenance label. + + Upstream, ``@example(...).via("...")`` only records where the + example came from, for tools that add or remove ``@example`` + decorators (HypoFuzz, for instance); it changes nothing at run + time there either. + """ return self