diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..cb27f5e319 --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,5 @@ +RELEASE_TYPE: patch + +We now use the :pep:`654` `ExceptionGroup `__ +type - provided by the :pypi:`exceptiongroup` backport on older Pythons - +to ensure that if multiple errors are raised in teardown, they will all propagate. diff --git a/hypothesis-python/setup.py b/hypothesis-python/setup.py index 7db41d56e2..40c7146da2 100644 --- a/hypothesis-python/setup.py +++ b/hypothesis-python/setup.py @@ -100,7 +100,11 @@ def local_file(name): description="A library for property-based testing", zip_safe=False, extras_require=extras, - install_requires=["attrs>=19.2.0", "sortedcontainers>=2.1.0,<3.0.0"], + install_requires=[ + "attrs>=19.2.0", + "exceptiongroup>=1.0.0rc8 ; python_version<'3.11.0b1'", + "sortedcontainers>=2.1.0,<3.0.0", + ], python_requires=">=3.7", classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/hypothesis-python/src/hypothesis/control.py b/hypothesis-python/src/hypothesis/control.py index ed47bb23bc..ebf2e53031 100644 --- a/hypothesis-python/src/hypothesis/control.py +++ b/hypothesis-python/src/hypothesis/control.py @@ -9,11 +9,11 @@ # obtain one at https://mozilla.org/MPL/2.0/. import math -import traceback from typing import NoReturn, Union from hypothesis import Verbosity, settings -from hypothesis.errors import CleanupFailed, InvalidArgument, UnsatisfiedAssumption +from hypothesis.errors import InvalidArgument, UnsatisfiedAssumption +from hypothesis.internal.compat import BaseExceptionGroup from hypothesis.internal.conjecture.data import ConjectureData from hypothesis.internal.validation import check_type from hypothesis.reporting import report, verbose_report @@ -74,18 +74,16 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, tb): self.assign_variable.__exit__(exc_type, exc_value, tb) - if self.close() and exc_type is None: - raise CleanupFailed() - - def close(self): - any_failed = False + errors = [] for task in self.tasks: try: task() - except BaseException: - any_failed = True - report(traceback.format_exc()) - return any_failed + except BaseException as err: + errors.append(err) + if errors: + if len(errors) == 1: + raise errors[0] from exc_value + raise BaseExceptionGroup("Cleanup failed", errors) from exc_value def cleanup(teardown): diff --git a/hypothesis-python/src/hypothesis/errors.py b/hypothesis-python/src/hypothesis/errors.py index a864c7374e..75fe7ee0f9 100644 --- a/hypothesis-python/src/hypothesis/errors.py +++ b/hypothesis-python/src/hypothesis/errors.py @@ -17,10 +17,6 @@ class _Trimmable(HypothesisException): """Hypothesis can trim these tracebacks even if they're raised internally.""" -class CleanupFailed(HypothesisException): - """At least one cleanup task failed and no other exception was raised.""" - - class UnsatisfiedAssumption(HypothesisException): """An internal error raised by assume. diff --git a/hypothesis-python/src/hypothesis/internal/compat.py b/hypothesis-python/src/hypothesis/internal/compat.py index 2d1db3d61b..78e4c216cd 100644 --- a/hypothesis-python/src/hypothesis/internal/compat.py +++ b/hypothesis-python/src/hypothesis/internal/compat.py @@ -16,11 +16,12 @@ try: BaseExceptionGroup = BaseExceptionGroup -except NameError: # pragma: no cover - try: - from exceptiongroup import BaseExceptionGroup as BaseExceptionGroup # for mypy - except ImportError: - BaseExceptionGroup = () # valid in isinstance and except clauses! + ExceptionGroup = ExceptionGroup # pragma: no cover +except NameError: + from exceptiongroup import ( + BaseExceptionGroup as BaseExceptionGroup, + ExceptionGroup as ExceptionGroup, + ) PYPY = platform.python_implementation() == "PyPy" WINDOWS = platform.system() == "Windows" diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/types.py b/hypothesis-python/src/hypothesis/strategies/_internal/types.py index a0ba4cd56f..617b360856 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/types.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/types.py @@ -28,7 +28,7 @@ from hypothesis import strategies as st from hypothesis.errors import InvalidArgument, ResolutionFailed -from hypothesis.internal.compat import PYPY +from hypothesis.internal.compat import PYPY, BaseExceptionGroup, ExceptionGroup from hypothesis.internal.conjecture.utils import many as conjecture_utils_many from hypothesis.strategies._internal.datetime import zoneinfo # type: ignore from hypothesis.strategies._internal.ipaddress import ( @@ -554,6 +554,16 @@ def _networks(bits): UnicodeTranslateError: st.builds( UnicodeTranslateError, st.text(), st.just(0), st.just(0), st.just("reason") ), + BaseExceptionGroup: st.builds( + BaseExceptionGroup, + st.text(), + st.lists(st.from_type(BaseException), min_size=1), + ), + ExceptionGroup: st.builds( + ExceptionGroup, + st.text(), + st.lists(st.from_type(Exception), min_size=1), + ), enumerate: st.builds(enumerate, st.just(())), filter: st.builds(filter, st.just(lambda _: None), st.just(())), map: st.builds(map, st.just(lambda _: None), st.just(())), @@ -569,21 +579,6 @@ def _networks(bits): _global_type_lookup[zoneinfo.ZoneInfo] = st.timezones() if PYPY: _global_type_lookup[builtins.sequenceiterator] = st.builds(iter, st.tuples()) # type: ignore -try: - BaseExceptionGroup # type: ignore # noqa -except NameError: - pass -else: # pragma: no cover - _global_type_lookup[BaseExceptionGroup] = st.builds( # type: ignore - BaseExceptionGroup, # type: ignore - st.text(), - st.lists(st.from_type(BaseException), min_size=1), - ) - _global_type_lookup[ExceptionGroup] = st.builds( # type: ignore - ExceptionGroup, # type: ignore - st.text(), - st.lists(st.from_type(Exception), min_size=1), - ) _global_type_lookup[type] = st.sampled_from( diff --git a/hypothesis-python/tests/cover/test_control.py b/hypothesis-python/tests/cover/test_control.py index 60fe18f1c6..e2bb120d08 100644 --- a/hypothesis-python/tests/cover/test_control.py +++ b/hypothesis-python/tests/cover/test_control.py @@ -20,7 +20,8 @@ event, note, ) -from hypothesis.errors import CleanupFailed, InvalidArgument +from hypothesis.errors import InvalidArgument +from hypothesis.internal.compat import ExceptionGroup from hypothesis.internal.conjecture.data import ConjectureData as TD from hypothesis.stateful import RuleBasedStateMachine, rule from hypothesis.strategies import integers @@ -73,44 +74,41 @@ def test_does_not_suppress_exceptions(): def test_suppresses_exceptions_in_teardown(): - with capture_out() as o: - with pytest.raises(AssertionError): - with bc(): + with pytest.raises(ValueError) as err: + with bc(): - def foo(): - raise ValueError() + def foo(): + raise ValueError - cleanup(foo) - raise AssertionError + cleanup(foo) + raise AssertionError - assert "ValueError" in o.getvalue() - assert _current_build_context.value is None + assert isinstance(err.value, ValueError) + assert isinstance(err.value.__cause__, AssertionError) def test_runs_multiple_cleanup_with_teardown(): - with capture_out() as o: - with pytest.raises(AssertionError): - with bc(): - - def foo(): - raise ValueError() + with pytest.raises(ExceptionGroup) as err: + with bc(): - cleanup(foo) + def foo(): + raise ValueError - def bar(): - raise TypeError() + def bar(): + raise TypeError - cleanup(foo) - cleanup(bar) - raise AssertionError + cleanup(foo) + cleanup(bar) + raise AssertionError - assert "ValueError" in o.getvalue() - assert "TypeError" in o.getvalue() + assert isinstance(err.value, ExceptionGroup) + assert isinstance(err.value.__cause__, AssertionError) + assert {type(e) for e in err.value.exceptions} == {ValueError, TypeError} assert _current_build_context.value is None def test_raises_error_if_cleanup_fails_but_block_does_not(): - with pytest.raises(CleanupFailed): + with pytest.raises(ValueError): with bc(): def foo(): diff --git a/hypothesis-python/tests/cover/test_float_utils.py b/hypothesis-python/tests/cover/test_float_utils.py index 0a2890040f..7122a13436 100644 --- a/hypothesis-python/tests/cover/test_float_utils.py +++ b/hypothesis-python/tests/cover/test_float_utils.py @@ -44,6 +44,8 @@ def test_next_float_equal(func, val): assert func(val) == val +# invalid order -> clamper is None: +@example(2.0, 1.0, 3.0) # exponent comparisons: @example(1, float_info.max, 0) @example(1, float_info.max, 1) diff --git a/hypothesis-python/tests/cover/test_stateful.py b/hypothesis-python/tests/cover/test_stateful.py index 45ed6bb8d8..1b1ac20842 100644 --- a/hypothesis-python/tests/cover/test_stateful.py +++ b/hypothesis-python/tests/cover/test_stateful.py @@ -981,6 +981,7 @@ def fail_eventually(self): def test_steps_printed_despite_pytest_fail(capsys): # Test for https://github.com/HypothesisWorks/hypothesis/issues/1372 + @Settings(print_blob=False) class RaisesProblem(RuleBasedStateMachine): @rule() def oops(self): diff --git a/hypothesis-python/tests/cover/test_verbosity.py b/hypothesis-python/tests/cover/test_verbosity.py index 764a0552ef..f26ce8b22c 100644 --- a/hypothesis-python/tests/cover/test_verbosity.py +++ b/hypothesis-python/tests/cover/test_verbosity.py @@ -61,7 +61,6 @@ def test_includes_progress_in_verbose_mode(): out = o.getvalue() assert out assert "Trying example: " in out - assert "Falsifying example: " in out def test_prints_initial_attempts_on_find():