Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simple initial uses of ExceptionGroup #3372

Merged
merged 5 commits into from
Jun 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
RELEASE_TYPE: patch

We now use the :pep:`654` `ExceptionGroup <https://docs.python.org/3.11/library/exceptions.html#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.
6 changes: 5 additions & 1 deletion hypothesis-python/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 9 additions & 11 deletions hypothesis-python/src/hypothesis/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

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

Question: do we need to use from with exception groups? I've never tried it :)

Copy link
Member Author

Choose a reason for hiding this comment

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

https://docs.python.org/3/tutorial/errors.html#exception-chaining

It just indicates that we're deliberately replacing the previous exception, instead of hitting another error while handling it.



def cleanup(teardown):
Expand Down
4 changes: 0 additions & 4 deletions hypothesis-python/src/hypothesis/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 6 additions & 5 deletions hypothesis-python/src/hypothesis/internal/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 11 additions & 16 deletions hypothesis-python/src/hypothesis/strategies/_internal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(())),
Expand All @@ -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(
Expand Down
48 changes: 23 additions & 25 deletions hypothesis-python/tests/cover/test_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
2 changes: 2 additions & 0 deletions hypothesis-python/tests/cover/test_float_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions hypothesis-python/tests/cover/test_stateful.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 0 additions & 1 deletion hypothesis-python/tests/cover/test_verbosity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down