Skip to content

Commit cc6f84e

Browse files
authored
Merge pull request #3308 from Zac-HD/use-exceptiongroup
Switch from `MultipleFailures` to PEP-654 `ExceptionGroup`
2 parents 0017d25 + 5ed3451 commit cc6f84e

25 files changed

+290
-331
lines changed

hypothesis-python/RELEASE.rst

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
RELEASE_TYPE: minor
2+
3+
Reporting of :obj:`multiple failing examples <hypothesis.settings.report_multiple_bugs>`
4+
now uses the :pep:`654` `ExceptionGroup <https://docs.python.org/3.11/library/exceptions.html#ExceptionGroup>`__ type, which is provided by the
5+
:pypi:`exceptiongroup` backport on Python 3.10 and earlier (:issue:`3175`).
6+
``hypothesis.errors.MultipleFailures`` is therefore deprecated.
7+
8+
Failing examples and other reports are now stored as :pep:`678` exception notes, which
9+
ensures that they will always appear together with the traceback and other information
10+
about their respective error.

hypothesis-python/src/_hypothesis_pytestplugin.py

+7
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,13 @@ def pytest_configure(config):
178178
pass
179179
core.global_force_seed = seed
180180

181+
core.pytest_shows_exceptiongroups = (
182+
sys.version_info[:2] >= (3, 11)
183+
## See https://github.com/pytest-dev/pytest/issues/9159
184+
# or pytest_version >= (7, 2) # TODO: fill in correct version here
185+
or config.getoption("tbstyle", "auto") == "native"
186+
)
187+
181188
@pytest.hookimpl(hookwrapper=True)
182189
def pytest_runtest_call(item):
183190
__tracebackhide__ = True

hypothesis-python/src/hypothesis/core.py

+69-79
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@
6060
HypothesisDeprecationWarning,
6161
HypothesisWarning,
6262
InvalidArgument,
63-
MultipleFailures,
6463
NoSuchExample,
6564
StopTest,
6665
Unsatisfiable,
@@ -69,6 +68,7 @@
6968
from hypothesis.executors import default_new_style_executor, new_style_executor
7069
from hypothesis.internal.compat import (
7170
PYPY,
71+
BaseExceptionGroup,
7272
bad_django_TestCase,
7373
get_type_hints,
7474
int_from_bytes,
@@ -126,6 +126,7 @@
126126

127127

128128
running_under_pytest = False
129+
pytest_shows_exceptiongroups = True
129130
global_force_seed = None
130131
_hypothesis_global_random = None
131132

@@ -436,7 +437,7 @@ def execute_explicit_examples(state, wrapped_test, arguments, kwargs, original_s
436437
err = new
437438

438439
yield (fragments_reported, err)
439-
if state.settings.report_multiple_bugs:
440+
if state.settings.report_multiple_bugs and pytest_shows_exceptiongroups:
440441
continue
441442
break
442443
finally:
@@ -575,7 +576,6 @@ def __init__(
575576
self.settings = settings
576577
self.last_exception = None
577578
self.falsifying_examples = ()
578-
self.__was_flaky = False
579579
self.random = random
580580
self.__test_runtime = None
581581
self.ever_executed = False
@@ -710,11 +710,10 @@ def run(data):
710710
)
711711
else:
712712
report("Failed to reproduce exception. Expected: \n" + traceback)
713-
self.__flaky(
714-
f"Hypothesis {text_repr} produces unreliable results: Falsified"
715-
" on the first call but did not on a subsequent one",
716-
cause=exception,
717-
)
713+
raise Flaky(
714+
f"Hypothesis {text_repr} produces unreliable results: "
715+
"Falsified on the first call but did not on a subsequent one"
716+
) from exception
718717
return result
719718

720719
def _execute_once_for_engine(self, data):
@@ -842,64 +841,57 @@ def run_engine(self):
842841

843842
if not self.falsifying_examples:
844843
return
845-
elif not self.settings.report_multiple_bugs:
844+
elif not (self.settings.report_multiple_bugs and pytest_shows_exceptiongroups):
846845
# Pretend that we only found one failure, by discarding the others.
847846
del self.falsifying_examples[:-1]
848847

849848
# The engine found one or more failures, so we need to reproduce and
850849
# report them.
851850

852-
flaky = 0
851+
errors_to_report = []
853852

854-
if runner.best_observed_targets:
855-
for line in describe_targets(runner.best_observed_targets):
856-
report(line)
857-
report("")
853+
report_lines = describe_targets(runner.best_observed_targets)
854+
if report_lines:
855+
report_lines.append("")
858856

859857
explanations = explanatory_lines(self.explain_traces, self.settings)
860858
for falsifying_example in self.falsifying_examples:
861859
info = falsifying_example.extra_information
860+
fragments = []
862861

863862
ran_example = ConjectureData.for_buffer(falsifying_example.buffer)
864-
self.__was_flaky = False
865863
assert info.__expected_exception is not None
866864
try:
867-
self.execute_once(
868-
ran_example,
869-
print_example=not self.is_find,
870-
is_final=True,
871-
expected_failure=(
872-
info.__expected_exception,
873-
info.__expected_traceback,
874-
),
875-
)
865+
with with_reporter(fragments.append):
866+
self.execute_once(
867+
ran_example,
868+
print_example=not self.is_find,
869+
is_final=True,
870+
expected_failure=(
871+
info.__expected_exception,
872+
info.__expected_traceback,
873+
),
874+
)
876875
except (UnsatisfiedAssumption, StopTest) as e:
877-
report(format_exception(e, e.__traceback__))
878-
self.__flaky(
876+
err = Flaky(
879877
"Unreliable assumption: An example which satisfied "
880878
"assumptions on the first run now fails it.",
881-
cause=e,
882879
)
880+
err.__cause__ = err.__context__ = e
881+
errors_to_report.append((fragments, err))
883882
except BaseException as e:
884883
# If we have anything for explain-mode, this is the time to report.
885884
for line in explanations[falsifying_example.interesting_origin]:
886-
report(line)
887-
888-
if len(self.falsifying_examples) <= 1:
889-
# There is only one failure, so we can report it by raising
890-
# it directly.
891-
raise
892-
893-
# We are reporting multiple failures, so we need to manually
894-
# print each exception's stack trace and information.
895-
tb = get_trimmed_traceback()
896-
report(format_exception(e, tb))
885+
fragments.append(line)
886+
errors_to_report.append(
887+
(fragments, e.with_traceback(get_trimmed_traceback()))
888+
)
897889

898890
finally:
899891
# Whether or not replay actually raised the exception again, we want
900892
# to print the reproduce_failure decorator for the failing example.
901893
if self.settings.print_blob:
902-
report(
894+
fragments.append(
903895
"\nYou can reproduce this example by temporarily adding "
904896
"@reproduce_failure(%r, %r) as a decorator on your test case"
905897
% (__version__, encode_failure(falsifying_example.buffer))
@@ -908,30 +900,38 @@ def run_engine(self):
908900
# hold on to a reference to ``data`` know that it's now been
909901
# finished and they can't draw more data from it.
910902
ran_example.freeze()
903+
_raise_to_user(errors_to_report, self.settings, report_lines)
911904

912-
if self.__was_flaky:
913-
flaky += 1
914-
915-
# If we only have one example then we should have raised an error or
916-
# flaky prior to this point.
917-
assert len(self.falsifying_examples) > 1
918905

919-
if flaky > 0:
920-
raise Flaky(
921-
f"Hypothesis found {len(self.falsifying_examples)} distinct failures, "
922-
f"but {flaky} of them exhibited some sort of flaky behaviour."
923-
)
924-
else:
925-
raise MultipleFailures(
926-
f"Hypothesis found {len(self.falsifying_examples)} distinct failures."
927-
)
906+
def add_note(exc, note):
907+
try:
908+
exc.add_note(note)
909+
except AttributeError:
910+
if not hasattr(exc, "__notes__"):
911+
exc.__notes__ = []
912+
exc.__notes__.append(note)
913+
914+
915+
def _raise_to_user(errors_to_report, settings, target_lines, trailer=""):
916+
"""Helper function for attaching notes and grouping multiple errors."""
917+
if settings.verbosity >= Verbosity.normal:
918+
for fragments, err in errors_to_report:
919+
for note in fragments:
920+
add_note(err, note)
921+
922+
if len(errors_to_report) == 1:
923+
_, the_error_hypothesis_found = errors_to_report[0]
924+
else:
925+
assert errors_to_report
926+
the_error_hypothesis_found = BaseExceptionGroup(
927+
f"Hypothesis found {len(errors_to_report)} distinct failures{trailer}.",
928+
[e for _, e in errors_to_report],
929+
)
928930

929-
def __flaky(self, message, *, cause):
930-
if len(self.falsifying_examples) <= 1:
931-
raise Flaky(message) from cause
932-
else:
933-
self.__was_flaky = True
934-
report("Flaky example! " + message)
931+
if settings.verbosity >= Verbosity.normal:
932+
for line in target_lines:
933+
add_note(the_error_hypothesis_found, line)
934+
raise the_error_hypothesis_found
935935

936936

937937
@contextlib.contextmanager
@@ -1189,23 +1189,11 @@ def wrapped_test(*arguments, **kwargs):
11891189
state, wrapped_test, arguments, kwargs, original_sig
11901190
)
11911191
)
1192-
with local_settings(state.settings):
1193-
if len(errors) > 1:
1194-
# If we're not going to report multiple bugs, we would have
1195-
# stopped running explicit examples at the first failure.
1196-
assert state.settings.report_multiple_bugs
1197-
for fragments, err in errors:
1198-
for f in fragments:
1199-
report(f)
1200-
report(format_exception(err, err.__traceback__))
1201-
raise MultipleFailures(
1202-
f"Hypothesis found {len(errors)} failures in explicit examples."
1203-
)
1204-
elif errors:
1205-
fragments, the_error_hypothesis_found = errors[0]
1206-
for f in fragments:
1207-
report(f)
1208-
raise the_error_hypothesis_found
1192+
if errors:
1193+
# If we're not going to report multiple bugs, we would have
1194+
# stopped running explicit examples at the first failure.
1195+
assert len(errors) == 1 or state.settings.report_multiple_bugs
1196+
_raise_to_user(errors, state.settings, [], " in explicit examples")
12091197

12101198
# If there were any explicit examples, they all ran successfully.
12111199
# The next step is to use the Conjecture engine to run the test on
@@ -1236,7 +1224,7 @@ def wrapped_test(*arguments, **kwargs):
12361224
state.run_engine()
12371225
except BaseException as e:
12381226
# The exception caught here should either be an actual test
1239-
# failure (or MultipleFailures), or some kind of fatal error
1227+
# failure (or BaseExceptionGroup), or some kind of fatal error
12401228
# that caused the engine to stop.
12411229

12421230
generated_seed = wrapped_test._hypothesis_internal_use_generated_seed
@@ -1262,7 +1250,9 @@ def wrapped_test(*arguments, **kwargs):
12621250
# which will actually appear in tracebacks is as clear as
12631251
# possible - "raise the_error_hypothesis_found".
12641252
the_error_hypothesis_found = e.with_traceback(
1265-
get_trimmed_traceback()
1253+
None
1254+
if isinstance(e, BaseExceptionGroup)
1255+
else get_trimmed_traceback()
12661256
)
12671257
raise the_error_hypothesis_found
12681258

hypothesis-python/src/hypothesis/errors.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,18 @@ class Frozen(HypothesisException):
124124
after freeze() has been called."""
125125

126126

127-
class MultipleFailures(_Trimmable):
128-
"""Indicates that Hypothesis found more than one distinct bug when testing
129-
your code."""
127+
def __getattr__(name):
128+
if name == "MultipleFailures":
129+
from hypothesis._settings import note_deprecation
130+
from hypothesis.internal.compat import BaseExceptionGroup
131+
132+
note_deprecation(
133+
"MultipleFailures is deprecated; use the builtin `BaseExceptionGroup` type "
134+
"instead, or `exceptiongroup.BaseExceptionGroup` before Python 3.11",
135+
since="RELEASEDAY",
136+
has_codemod=False, # This would be a great PR though!
137+
)
138+
return BaseExceptionGroup
130139

131140

132141
class DeadlineExceeded(_Trimmable):

hypothesis-python/src/hypothesis/internal/escalation.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def get_trimmed_traceback(exception=None):
8686
else:
8787
tb = exception.__traceback__
8888
# Avoid trimming the traceback if we're in verbose mode, or the error
89-
# was raised inside Hypothesis (and is not a MultipleFailures)
89+
# was raised inside Hypothesis
9090
if hypothesis.settings.default.verbosity >= hypothesis.Verbosity.debug or (
9191
is_hypothesis_file(traceback.extract_tb(tb)[-1][0])
9292
and not isinstance(exception, _Trimmable)

hypothesis-python/src/hypothesis/reporting.py

-4
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@
1515
from hypothesis.utils.dynamicvariables import DynamicVariable
1616

1717

18-
def silent(value):
19-
pass
20-
21-
2218
def default(value):
2319
try:
2420
print(value)

hypothesis-python/tests/cover/test_arbitrary_data.py

+13-24
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,9 @@
1111
import pytest
1212
from pytest import raises
1313

14-
from hypothesis import find, given, reporting, strategies as st
14+
from hypothesis import find, given, strategies as st
1515
from hypothesis.errors import InvalidArgument
1616

17-
from tests.common.utils import capture_out
18-
1917

2018
@given(st.integers(), st.data())
2119
def test_conditional_draw(x, data):
@@ -32,13 +30,10 @@ def test(data):
3230
if y in x:
3331
raise ValueError()
3432

35-
with raises(ValueError):
36-
with capture_out() as out:
37-
with reporting.with_reporter(reporting.default):
38-
test()
39-
result = out.getvalue()
40-
assert "Draw 1: [0, 0]" in result
41-
assert "Draw 2: 0" in result
33+
with raises(ValueError) as err:
34+
test()
35+
assert "Draw 1: [0, 0]" in err.value.__notes__
36+
assert "Draw 2: 0" in err.value.__notes__
4237

4338

4439
def test_prints_labels_if_given_on_failure():
@@ -50,13 +45,10 @@ def test(data):
5045
x.remove(y)
5146
assert y not in x
5247

53-
with raises(AssertionError):
54-
with capture_out() as out:
55-
with reporting.with_reporter(reporting.default):
56-
test()
57-
result = out.getvalue()
58-
assert "Draw 1 (Some numbers): [0, 0]" in result
59-
assert "Draw 2 (A number): 0" in result
48+
with raises(AssertionError) as err:
49+
test()
50+
assert "Draw 1 (Some numbers): [0, 0]" in err.value.__notes__
51+
assert "Draw 2 (A number): 0" in err.value.__notes__
6052

6153

6254
def test_given_twice_is_same():
@@ -66,13 +58,10 @@ def test(data1, data2):
6658
data2.draw(st.integers())
6759
raise ValueError()
6860

69-
with raises(ValueError):
70-
with capture_out() as out:
71-
with reporting.with_reporter(reporting.default):
72-
test()
73-
result = out.getvalue()
74-
assert "Draw 1: 0" in result
75-
assert "Draw 2: 0" in result
61+
with raises(ValueError) as err:
62+
test()
63+
assert "Draw 1: 0" in err.value.__notes__
64+
assert "Draw 2: 0" in err.value.__notes__
7665

7766

7867
def test_errors_when_used_in_find():

0 commit comments

Comments
 (0)