From 38c5208da4683f0fce2359422d78aca5bb9334e0 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Thu, 19 Sep 2024 15:53:09 +0200 Subject: [PATCH 01/10] first draft of handling marker exceptions wrapped in exceptiongroup --- hypothesis-python/src/hypothesis/core.py | 46 +++++++++++++++- .../tests/cover/test_exceptiongroup.py | 54 +++++++++++++++++++ requirements/test.in | 1 + 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 hypothesis-python/tests/cover/test_exceptiongroup.py diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index 304ba614d0..c696235e08 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -61,6 +61,7 @@ FlakyFailure, FlakyReplay, Found, + Frozen, HypothesisException, HypothesisWarning, InvalidArgument, @@ -148,6 +149,8 @@ else: # pragma: no cover EllipsisType = type(Ellipsis) +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup TestFunc = TypeVar("TestFunc", bound=Callable) @@ -768,6 +771,45 @@ def execute(data, function): return default_executor +@contextlib.contextmanager +def unwrap_exception_group(): + def _flatten_group(excgroup: BaseExceptionGroup) -> list[BaseException]: + found_exceptions = [] + for exc in excgroup.exceptions: + if isinstance(exc, BaseExceptionGroup): + found_exceptions.extend(_flatten_group(exc)) + else: + found_exceptions.append(exc) + return found_exceptions + + try: + yield + except BaseExceptionGroup as excgroup: + # Found? RewindRecursive? FlakyReplay? + marker_exceptions = (StopTest, UnsatisfiedAssumption) + frozen_exceptions, non_frozen_exceptions = excgroup.split(Frozen) + marker_exceptions, user_exceptions = non_frozen_exceptions.split(lambda e: isinstance(e, marker_exceptions)) + + # TODO: not a great variable name + if user_exceptions: + raise + + if non_frozen_exceptions: + flattened_non_frozen_exceptions = _flatten_group(non_frozen_exceptions) + if len(flattened_non_frozen_exceptions) == 1: + raise flattened_non_frozen_exceptions[0] from None + # multiple non-frozen exceptions, re-raise the entire group + raise + + flattened_frozen_exceptions = _flatten_group(frozen_exceptions) + # we only have frozen exceptions + if len(flattened_frozen_exceptions) == 1: + raise flattened_frozen_exceptions[0] from excgroup + + # multiple frozen exceptions + # TODO: raise a group? The first one? None of them? + raise frozen_exceptions[0] from excgroup + class StateForActualGivenExecution: def __init__(self, stuff, test, settings, random, wrapped_test): self.test_runner = get_executor(stuff.selfy) @@ -841,7 +883,7 @@ def execute_once( @proxies(self.test) def test(*args, **kwargs): - with ensure_free_stackframes(): + with unwrap_exception_group(), ensure_free_stackframes(): return self.test(*args, **kwargs) else: @@ -853,7 +895,7 @@ def test(*args, **kwargs): arg_gctime = gc_cumulative_time() start = time.perf_counter() try: - with ensure_free_stackframes(): + with unwrap_exception_group(), ensure_free_stackframes(): result = self.test(*args, **kwargs) finally: finish = time.perf_counter() diff --git a/hypothesis-python/tests/cover/test_exceptiongroup.py b/hypothesis-python/tests/cover/test_exceptiongroup.py new file mode 100644 index 0000000000..ed4c6654f3 --- /dev/null +++ b/hypothesis-python/tests/cover/test_exceptiongroup.py @@ -0,0 +1,54 @@ +import sys +import asyncio + +from trio.testing import RaisesGroup +from hypothesis import errors, given, strategies as st +import pytest + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + +def test_exceptiongroup(): + @given(st.data()) + def test_function(data): + async def task(pred): + return data.draw(st.booleans().filter(pred)) + + async def _main(): + async with asyncio.TaskGroup() as tg: + tg.create_task(task(bool)) + tg.create_task(task(lambda _: False)) + asyncio.run(_main()) + with pytest.raises(errors.Unsatisfiable): + test_function() + +def test_exceptiongroup_nested(): + @given(st.data()) + def test_function(data): + async def task(pred): + return data.draw(st.booleans().filter(pred)) + + async def _main(): + async with asyncio.TaskGroup(): + async with asyncio.TaskGroup() as tg2: + tg2.create_task(task(bool)) + tg2.create_task(task(lambda _: False)) + asyncio.run(_main()) + with pytest.raises(errors.Unsatisfiable): + test_function() + + +def test_exceptiongroup_user_originated(): + @given(st.data()) + def test_function(data): + raise ExceptionGroup("foo", [ValueError(), ValueError()]) + with RaisesGroup(ValueError, ValueError, match="foo"): + test_function() + + + @given(st.data()) + def test_single_exc_group(data): + raise ExceptionGroup("important message for user", [ValueError()]) + + with RaisesGroup(ValueError, match="important message for user"): + test_single_exc_group() diff --git a/requirements/test.in b/requirements/test.in index 72e4385648..29920ff270 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -2,3 +2,4 @@ attrs==24.1.0 # too early for https://github.com/python-attrs/attrs/pull/1329 pexpect pytest pytest-xdist +trio # for trio.testing.RaisesGroup, until pytest merges a version From 057303a71e1c634eb897adc7addb472485688260 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 24 Sep 2024 16:58:58 +0200 Subject: [PATCH 02/10] progress, and new question marks --- hypothesis-python/src/hypothesis/core.py | 38 +++--- .../tests/cover/test_exceptiongroup.py | 115 +++++++++++++++--- requirements/test.in | 1 - 3 files changed, 124 insertions(+), 30 deletions(-) diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index c696235e08..405c5021a0 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -149,9 +149,6 @@ else: # pragma: no cover EllipsisType = type(Ellipsis) -if sys.version_info < (3, 11): - from exceptiongroup import ExceptionGroup - TestFunc = TypeVar("TestFunc", bound=Callable) @@ -786,9 +783,19 @@ def _flatten_group(excgroup: BaseExceptionGroup) -> list[BaseException]: yield except BaseExceptionGroup as excgroup: # Found? RewindRecursive? FlakyReplay? - marker_exceptions = (StopTest, UnsatisfiedAssumption) + marker_exceptions = (StopTest, HypothesisException) frozen_exceptions, non_frozen_exceptions = excgroup.split(Frozen) - marker_exceptions, user_exceptions = non_frozen_exceptions.split(lambda e: isinstance(e, marker_exceptions)) + + # group only contains Frozen, reraise the group + # it doesn't matter what we raise, since any exceptions get disregarded + # and reraised as StopTest if data got frozen. + if non_frozen_exceptions is None: + raise + # in all other cases they are discarded + + marker_exceptions, user_exceptions = non_frozen_exceptions.split( + lambda e: isinstance(e, marker_exceptions) + ) # TODO: not a great variable name if user_exceptions: @@ -798,17 +805,12 @@ def _flatten_group(excgroup: BaseExceptionGroup) -> list[BaseException]: flattened_non_frozen_exceptions = _flatten_group(non_frozen_exceptions) if len(flattened_non_frozen_exceptions) == 1: raise flattened_non_frozen_exceptions[0] from None - # multiple non-frozen exceptions, re-raise the entire group - raise - - flattened_frozen_exceptions = _flatten_group(frozen_exceptions) - # we only have frozen exceptions - if len(flattened_frozen_exceptions) == 1: - raise flattened_frozen_exceptions[0] from excgroup + # multiple marker exceptions. If we re-raise the whole group we break + # a bunch of logic so ....? + # is it possible to get multiple StopTest? Both a StopTest and + # HypothesisException? Multiple HypothesisException? + raise flattened_non_frozen_exceptions[0] from excgroup - # multiple frozen exceptions - # TODO: raise a group? The first one? None of them? - raise frozen_exceptions[0] from excgroup class StateForActualGivenExecution: def __init__(self, stuff, test, settings, random, wrapped_test): @@ -1128,6 +1130,9 @@ def _execute_once_for_engine(self, data: ConjectureData) -> None: # This can happen if an error occurred in a finally # block somewhere, suppressing our original StopTest. # We raise a new one here to resume normal operation. + # TODO: this should maybe inspect the stacktrace to see that the above + # mentioned story is true? I.e. reraise as StopTest iff there is a + # StopTest somewhere in the tree of e.__context__ raise StopTest(data.testcounter) from e else: # The test failed by raising an exception, so we inform the @@ -1291,6 +1296,9 @@ def run_engine(self): ran_example.slice_comments = falsifying_example.slice_comments tb = None origin = None + # this assert is failing in test_exceptiongroup_multiple_stop and I have + # no clue why + assert info is not None assert info._expected_exception is not None try: with with_reporter(fragments.append): diff --git a/hypothesis-python/tests/cover/test_exceptiongroup.py b/hypothesis-python/tests/cover/test_exceptiongroup.py index ed4c6654f3..f8935c65ca 100644 --- a/hypothesis-python/tests/cover/test_exceptiongroup.py +++ b/hypothesis-python/tests/cover/test_exceptiongroup.py @@ -1,14 +1,27 @@ -import sys +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Copyright the Hypothesis Authors. +# Individual contributors are listed in AUTHORS.rst and the git log. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. + import asyncio +from typing import Callable -from trio.testing import RaisesGroup -from hypothesis import errors, given, strategies as st import pytest -if sys.version_info < (3, 11): - from exceptiongroup import ExceptionGroup +from hypothesis import errors, given, strategies as st +from hypothesis.internal.compat import ExceptionGroup +from hypothesis.strategies import DataObject + + +def test_exceptiongroup_discard_frozen(): + """Basic test that raises Frozen+Unsatisfiable. + Frozen is thrown out, and Unsatisfiable is raised""" -def test_exceptiongroup(): @given(st.data()) def test_function(data): async def task(pred): @@ -18,37 +31,111 @@ async def _main(): async with asyncio.TaskGroup() as tg: tg.create_task(task(bool)) tg.create_task(task(lambda _: False)) + asyncio.run(_main()) + with pytest.raises(errors.Unsatisfiable): test_function() -def test_exceptiongroup_nested(): + +def test_exceptiongroup_nested() -> None: @given(st.data()) - def test_function(data): - async def task(pred): + def test_function(data: DataObject) -> None: + async def task(pred: Callable[[bool], bool]) -> None: return data.draw(st.booleans().filter(pred)) - async def _main(): + async def _main() -> None: async with asyncio.TaskGroup(): async with asyncio.TaskGroup() as tg2: tg2.create_task(task(bool)) tg2.create_task(task(lambda _: False)) + asyncio.run(_main()) + with pytest.raises(errors.Unsatisfiable): test_function() -def test_exceptiongroup_user_originated(): +def test_exceptiongroup_user_originated() -> None: @given(st.data()) def test_function(data): raise ExceptionGroup("foo", [ValueError(), ValueError()]) - with RaisesGroup(ValueError, ValueError, match="foo"): - test_function() + with pytest.raises(ExceptionGroup) as exc_info: + test_function() + e = exc_info.value + assert e.message == "foo" + assert isinstance(e, ExceptionGroup) + assert len(e.exceptions) == 2 + assert all(isinstance(child_e, ValueError) for child_e in e.exceptions) @given(st.data()) def test_single_exc_group(data): raise ExceptionGroup("important message for user", [ValueError()]) - with RaisesGroup(ValueError, match="important message for user"): + with pytest.raises(ExceptionGroup) as exc_info: test_single_exc_group() + e = exc_info.value + assert e.message == "important message for user" + assert isinstance(e, ExceptionGroup) + assert len(e.exceptions) == 1 + assert isinstance(e.exceptions[0], ValueError) + + +def test_exceptiongroup_multiple_stop() -> None: + # or well, I'm trying to get multiple StopTest, but instead I'm getting a strange + # AttributeError + @given(st.data()) + def test_function(data): + async def task(d: DataObject) -> None: + d.conjecture_data.mark_interesting() + + async def _main(d: DataObject) -> None: + async with asyncio.TaskGroup() as tg: + tg.create_task(task(d)) + tg.create_task(task(d)) + + asyncio.run(_main(data)) + + test_function() + + +# if intended, will probably remove this test, and either way probably belong somewhere else +def test_frozen_things(): + # Hypothesis reraises the TypeError as a StopTest, because the data is Frozen. + # Doesn't seem great, but I suppose it is intentional? + @given(st.data()) + def foo(data): + data.conjecture_data.freeze() + raise TypeError("oops") + + foo() + + +# if above is intended, then this is supposedly also intended? +def test_frozen_data_and_critical_user_exception(): + @given(st.data()) + def test_function(data): + data.conjecture_data.freeze() + + async def task(d: DataObject) -> None: + d.draw(st.booleans()) + + async def task2() -> None: + raise TypeError("Critical error") + + async def _main(d: DataObject) -> None: + async with asyncio.TaskGroup() as tg: + tg.create_task(task(d)) + tg.create_task(task2()) + + asyncio.run(_main(data)) + + # does not raise anything, the TypeError is suppressed + test_function() + # with pytest.raises(ExceptionGroup) as exc_info: + # e = exc_info.value + # assert isinstance(e, ExceptionGroup) + # assert len(e.exceptions) == 2 + # assert isinstance(e.exceptions[0], errors.Frozen) + # assert isinstance(e.exceptions[1], TypeError) diff --git a/requirements/test.in b/requirements/test.in index 29920ff270..72e4385648 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -2,4 +2,3 @@ attrs==24.1.0 # too early for https://github.com/python-attrs/attrs/pull/1329 pexpect pytest pytest-xdist -trio # for trio.testing.RaisesGroup, until pytest merges a version From dca18e4e8036af91cc074c444c97b4942007f010 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Wed, 25 Sep 2024 15:37:46 +0200 Subject: [PATCH 03/10] move test_exceptiongroup to nocover --- hypothesis-python/tests/{cover => nocover}/test_exceptiongroup.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename hypothesis-python/tests/{cover => nocover}/test_exceptiongroup.py (100%) diff --git a/hypothesis-python/tests/cover/test_exceptiongroup.py b/hypothesis-python/tests/nocover/test_exceptiongroup.py similarity index 100% rename from hypothesis-python/tests/cover/test_exceptiongroup.py rename to hypothesis-python/tests/nocover/test_exceptiongroup.py From 14be2023f5c6db94383c6d668a249befd4c7d345 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Wed, 25 Sep 2024 15:40:39 +0200 Subject: [PATCH 04/10] add cover tests that can run on list[BaseException]: +def unwrap_exception_group() -> Generator[None, None, None]: + T = TypeVar("T", bound=BaseException) + + def _flatten_group(excgroup: BaseExceptionGroup[T]) -> list[T]: found_exceptions = [] for exc in excgroup.exceptions: if isinstance(exc, BaseExceptionGroup): @@ -782,8 +785,6 @@ def _flatten_group(excgroup: BaseExceptionGroup) -> list[BaseException]: try: yield except BaseExceptionGroup as excgroup: - # Found? RewindRecursive? FlakyReplay? - marker_exceptions = (StopTest, HypothesisException) frozen_exceptions, non_frozen_exceptions = excgroup.split(Frozen) # group only contains Frozen, reraise the group @@ -793,23 +794,34 @@ def _flatten_group(excgroup: BaseExceptionGroup) -> list[BaseException]: raise # in all other cases they are discarded - marker_exceptions, user_exceptions = non_frozen_exceptions.split( - lambda e: isinstance(e, marker_exceptions) + # Can RewindRecursive end up in this group? + _, user_exceptions = non_frozen_exceptions.split( + lambda e: isinstance(e, (StopTest, HypothesisException)) ) - # TODO: not a great variable name - if user_exceptions: + # this might contain marker exceptions, or internal errors, but not frozen. + if user_exceptions is not None: raise - if non_frozen_exceptions: - flattened_non_frozen_exceptions = _flatten_group(non_frozen_exceptions) - if len(flattened_non_frozen_exceptions) == 1: - raise flattened_non_frozen_exceptions[0] from None - # multiple marker exceptions. If we re-raise the whole group we break - # a bunch of logic so ....? - # is it possible to get multiple StopTest? Both a StopTest and - # HypothesisException? Multiple HypothesisException? - raise flattened_non_frozen_exceptions[0] from excgroup + # single marker exception - reraise it + flattened_non_frozen_exceptions = _flatten_group(non_frozen_exceptions) + if len(flattened_non_frozen_exceptions) == 1: + raise flattened_non_frozen_exceptions[0] from None + + # multiple marker exceptions. If we re-raise the whole group we break + # a bunch of logic so ....? + stoptests, non_stoptests = non_frozen_exceptions.split(StopTest) + + # TODO: stoptest+hypothesisexception ...? Is it possible? If so, what do? + + if non_stoptests: + # TODO: multiple marker exceptions is easy to produce, but the logic in the + # engine does not handle it... so we just reraise the first one for now. + raise _flatten_group(non_stoptests)[0] from None + assert stoptests is not None + + # multiple stoptests: raising the one with the lowest testcounter + raise min(_flatten_group(stoptests), key=lambda s_e: s_e.testcounter) class StateForActualGivenExecution: @@ -1296,8 +1308,6 @@ def run_engine(self): ran_example.slice_comments = falsifying_example.slice_comments tb = None origin = None - # this assert is failing in test_exceptiongroup_multiple_stop and I have - # no clue why assert info is not None assert info._expected_exception is not None try: diff --git a/hypothesis-python/src/hypothesis/internal/escalation.py b/hypothesis-python/src/hypothesis/internal/escalation.py index 9c242ba0c2..2fd797882b 100644 --- a/hypothesis-python/src/hypothesis/internal/escalation.py +++ b/hypothesis-python/src/hypothesis/internal/escalation.py @@ -13,9 +13,10 @@ import sys import textwrap import traceback +from functools import partial from inspect import getframeinfo from pathlib import Path -from typing import Dict, NamedTuple, Optional, Type +from typing import Dict, NamedTuple, Optional, Tuple, Type import hypothesis from hypothesis.errors import _Trimmable @@ -107,20 +108,26 @@ def __str__(self) -> str: return f"{self.exc_type.__name__} at {self.filename}:{self.lineno}{ctx}{group}" @classmethod - def from_exception(cls, exception: BaseException, /) -> "InterestingOrigin": + def from_exception(cls, exception: BaseException, /, handled_exceptions: Tuple[BaseException,...] = ()) -> "InterestingOrigin": + if exception in handled_exceptions: + exception.__context__ = None filename, lineno = None, None if tb := get_trimmed_traceback(exception): filename, lineno, *_ = traceback.extract_tb(tb)[-1] + + # Quick dirty fix for #4115 + handled_exceptions=(*handled_exceptions, exception) + return cls( type(exception), filename, lineno, # Note that if __cause__ is set it is always equal to __context__, explicitly # to support introspection when debugging, so we can use that unconditionally. - cls.from_exception(exception.__context__) if exception.__context__ else (), + cls.from_exception(exception.__context__, handled_exceptions=handled_exceptions) if exception.__context__ else (), # We distinguish exception groups by the inner exceptions, as for __context__ ( - tuple(map(cls.from_exception, exception.exceptions)) + tuple(map(partial(cls.from_exception, handled_exceptions=handled_exceptions), exception.exceptions)) if isinstance(exception, BaseExceptionGroup) else () ), diff --git a/hypothesis-python/tests/cover/test_exceptiongroup.py b/hypothesis-python/tests/cover/test_exceptiongroup.py new file mode 100644 index 0000000000..58835b1965 --- /dev/null +++ b/hypothesis-python/tests/cover/test_exceptiongroup.py @@ -0,0 +1,102 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Copyright the Hypothesis Authors. +# Individual contributors are listed in AUTHORS.rst and the git log. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. + +import pytest + +from hypothesis.internal.compat import BaseExceptionGroup, ExceptionGroup +from hypothesis import errors, given, strategies as st +from hypothesis.strategies import DataObject + + +def test_discard_frozen() -> None: + @given(st.data()) + def discard_frozen(data: DataObject) -> None: + # raising Frozen doesn't actually do anything, what matters is + # whether the data is frozen. + data.conjecture_data.freeze() + raise ExceptionGroup("", [errors.Frozen()]) + discard_frozen() + + +def test_discard_multiple_frozen() -> None: + @given(st.data()) + def discard_multiple_frozen(data: DataObject) -> None: + data.conjecture_data.freeze() + raise ExceptionGroup("", [errors.Frozen(), errors.Frozen()]) + discard_multiple_frozen() + +def test_user_error_and_frozen() -> None: + @given(st.data()) + def user_error_and_frozen(data: DataObject) -> None: + raise ExceptionGroup("", [errors.Frozen(), TypeError()]) + with pytest.raises(ExceptionGroup) as excinfo: + user_error_and_frozen() + e = excinfo.value + assert isinstance(e, ExceptionGroup) + assert len(e.exceptions) == 2 + assert isinstance(e.exceptions[0], errors.Frozen) + assert isinstance(e.exceptions[1], TypeError) + +def test_user_error_and_stoptest() -> None: + # if the code base had "proper" handling of exceptiongroups, the StopTest would + # probably be handled by an except*. + # TODO: which I suppose is an argument in favor of stripping it?? + @given(st.data()) + def user_error_and_stoptest(data: DataObject) -> None: + raise BaseExceptionGroup("", [errors.StopTest(data.conjecture_data.testcounter), TypeError()]) + with pytest.raises(BaseExceptionGroup) as excinfo: + user_error_and_stoptest() + e = excinfo.value + assert isinstance(e, BaseExceptionGroup) + assert len(e.exceptions) == 2 + assert isinstance(e.exceptions[0], errors.StopTest) + assert isinstance(e.exceptions[1], TypeError) + +def test_lone_user_error() -> None: + # we don't want to unwrap exceptiongroups, since they might contain + # useful debugging info + @given(st.data()) + def lone_user_error(data: DataObject) -> None: + raise ExceptionGroup("foo", [TypeError()]) + with pytest.raises(ExceptionGroup) as excinfo: + lone_user_error() + e = excinfo.value + assert isinstance(e, ExceptionGroup) + assert len(e.exceptions) == 1 + assert isinstance(e.exceptions[0], TypeError) + +def test_frozen_and_stoptest() -> None: + # frozen+stoptest => strip frozen and let engine handle StopTest + @given(st.data()) + def frozen_and_stoptest(data: DataObject) -> None: + raise BaseExceptionGroup("", [errors.StopTest(data.conjecture_data.testcounter), errors.Frozen()]) + frozen_and_stoptest() + +def test_multiple_stoptest_1() -> None: + # multiple stoptest, reraise the one with lowest testcounter + # I don't know if/how this can happen, nested tests perhaps?? + @given(st.data()) + def multiple_stoptest(data: DataObject) -> None: + c = data.conjecture_data.testcounter + raise BaseExceptionGroup("", [errors.StopTest(c), errors.StopTest(c+1)]) + multiple_stoptest() + +def test_multiple_stoptest_2() -> None: + # the lower value is raised, which does not match data.conjecture_data.testcounter + # so it is not handled by the engine + @given(st.data()) + def multiple_stoptest_2(data: DataObject) -> None: + c = data.conjecture_data.testcounter + raise BaseExceptionGroup("", [errors.StopTest(c), errors.StopTest(c-1)]) + + with pytest.raises(errors.StopTest) as excinfo: + multiple_stoptest_2() + e = excinfo.value + assert isinstance(e, errors.StopTest) diff --git a/hypothesis-python/tests/nocover/test_exceptiongroup.py b/hypothesis-python/tests/nocover/test_exceptiongroup.py index f8935c65ca..ba1c1b7495 100644 --- a/hypothesis-python/tests/nocover/test_exceptiongroup.py +++ b/hypothesis-python/tests/nocover/test_exceptiongroup.py @@ -9,14 +9,20 @@ # obtain one at https://mozilla.org/MPL/2.0/. import asyncio +import sys from typing import Callable import pytest -from hypothesis import errors, given, strategies as st +from hypothesis import errors, given, reject, strategies as st from hypothesis.internal.compat import ExceptionGroup from hypothesis.strategies import DataObject +# this file is not typechecked by mypy, which only runs py310 + +if sys.version_info < (3, 11): + pytest.skip("asyncio.TaskGroup not available on None: @given(st.data()) def test_function(data: DataObject) -> None: async def task(pred: Callable[[bool], bool]) -> None: - return data.draw(st.booleans().filter(pred)) + data.draw(st.booleans().filter(pred)) async def _main() -> None: async with asyncio.TaskGroup(): @@ -83,12 +89,13 @@ def test_single_exc_group(data): def test_exceptiongroup_multiple_stop() -> None: - # or well, I'm trying to get multiple StopTest, but instead I'm getting a strange - # AttributeError @given(st.data()) def test_function(data): async def task(d: DataObject) -> None: - d.conjecture_data.mark_interesting() + ... + # idk how to intentionally raise StopTest here, without mucking + # around with internals *a lot* in a way that nobody would ever + # be expected to do async def _main(d: DataObject) -> None: async with asyncio.TaskGroup() as tg: @@ -100,6 +107,53 @@ async def _main(d: DataObject) -> None: test_function() +def test_exceptiongroup_stop_and_hypothesisexception() -> None: + # idk how to intentionally raise StopTest + ... + + +def test_exceptiongroup_multiple_hypothesisexception() -> None: + # multiple UnsatisfiedAssumption => first one is reraised => engine suppresses it + + @given(st.integers(min_value=0, max_value=3)) + def test_function(val: int) -> None: + async def task(value: int) -> None: + if value == 0: + reject() + + async def _main(value: int) -> None: + async with asyncio.TaskGroup() as tg: + tg.create_task(task(value)) + tg.create_task(task(value)) + + asyncio.run(_main(val)) + + test_function() + + +def test_exceptiongroup_multiple_InvalidArgument() -> None: + # multiple InvalidArgument => only first one is reraised... which seems bad. + # But raising a group might break ghostwriter(?) + + @given(st.data()) + def test_function(data: DataObject) -> None: + async def task1(d: DataObject) -> None: + d.draw(st.integers(max_value=1, min_value=3)) + + async def task2(d: DataObject) -> None: + d.draw(st.integers(max_value=2, min_value=3)) + + async def _main(d: DataObject) -> None: + async with asyncio.TaskGroup() as tg: + tg.create_task(task1(d)) + tg.create_task(task2(d)) + + asyncio.run(_main(data)) + + with pytest.raises(errors.InvalidArgument): + test_function() + + # if intended, will probably remove this test, and either way probably belong somewhere else def test_frozen_things(): # Hypothesis reraises the TypeError as a StopTest, because the data is Frozen. @@ -139,3 +193,27 @@ async def _main(d: DataObject) -> None: # assert len(e.exceptions) == 2 # assert isinstance(e.exceptions[0], errors.Frozen) # assert isinstance(e.exceptions[1], TypeError) + + +# FIXME: temporarily added while debugging #4115 +def test_recursive_exception(): + @given(st.data()) + def test_function(data): + try: + raise ExceptionGroup("", [ValueError()]) + except ExceptionGroup as eg: + raise eg.exceptions[0] from None + + with pytest.raises(ValueError): + test_function() + + +def test_recursive_exception2(): + @given(st.data()) + def test_function(data): + k = ValueError() + k.__context__ = k + raise k + + with pytest.raises(ValueError): + test_function() From 1d73c19081d73d410c85eb9fb31238b313261b82 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Fri, 27 Sep 2024 12:43:12 +0200 Subject: [PATCH 05/10] fix coverage --- .../src/hypothesis/internal/escalation.py | 26 ++++- .../tests/cover/test_exceptiongroup.py | 95 +++++++++++++++++-- .../tests/nocover/test_exceptiongroup.py | 24 ----- 3 files changed, 108 insertions(+), 37 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/escalation.py b/hypothesis-python/src/hypothesis/internal/escalation.py index 2fd797882b..5fc48360fa 100644 --- a/hypothesis-python/src/hypothesis/internal/escalation.py +++ b/hypothesis-python/src/hypothesis/internal/escalation.py @@ -108,7 +108,12 @@ def __str__(self) -> str: return f"{self.exc_type.__name__} at {self.filename}:{self.lineno}{ctx}{group}" @classmethod - def from_exception(cls, exception: BaseException, /, handled_exceptions: Tuple[BaseException,...] = ()) -> "InterestingOrigin": + def from_exception( + cls, + exception: BaseException, + /, + handled_exceptions: Tuple[BaseException, ...] = (), + ) -> "InterestingOrigin": if exception in handled_exceptions: exception.__context__ = None filename, lineno = None, None @@ -116,7 +121,7 @@ def from_exception(cls, exception: BaseException, /, handled_exceptions: Tuple[B filename, lineno, *_ = traceback.extract_tb(tb)[-1] # Quick dirty fix for #4115 - handled_exceptions=(*handled_exceptions, exception) + handled_exceptions = (*handled_exceptions, exception) return cls( type(exception), @@ -124,10 +129,23 @@ def from_exception(cls, exception: BaseException, /, handled_exceptions: Tuple[B lineno, # Note that if __cause__ is set it is always equal to __context__, explicitly # to support introspection when debugging, so we can use that unconditionally. - cls.from_exception(exception.__context__, handled_exceptions=handled_exceptions) if exception.__context__ else (), + ( + cls.from_exception( + exception.__context__, handled_exceptions=handled_exceptions + ) + if exception.__context__ + else () + ), # We distinguish exception groups by the inner exceptions, as for __context__ ( - tuple(map(partial(cls.from_exception, handled_exceptions=handled_exceptions), exception.exceptions)) + tuple( + map( + partial( + cls.from_exception, handled_exceptions=handled_exceptions + ), + exception.exceptions, + ) + ) if isinstance(exception, BaseExceptionGroup) else () ), diff --git a/hypothesis-python/tests/cover/test_exceptiongroup.py b/hypothesis-python/tests/cover/test_exceptiongroup.py index 58835b1965..d558d0c983 100644 --- a/hypothesis-python/tests/cover/test_exceptiongroup.py +++ b/hypothesis-python/tests/cover/test_exceptiongroup.py @@ -10,8 +10,8 @@ import pytest -from hypothesis.internal.compat import BaseExceptionGroup, ExceptionGroup from hypothesis import errors, given, strategies as st +from hypothesis.internal.compat import BaseExceptionGroup, ExceptionGroup from hypothesis.strategies import DataObject @@ -22,6 +22,7 @@ def discard_frozen(data: DataObject) -> None: # whether the data is frozen. data.conjecture_data.freeze() raise ExceptionGroup("", [errors.Frozen()]) + discard_frozen() @@ -30,12 +31,15 @@ def test_discard_multiple_frozen() -> None: def discard_multiple_frozen(data: DataObject) -> None: data.conjecture_data.freeze() raise ExceptionGroup("", [errors.Frozen(), errors.Frozen()]) + discard_multiple_frozen() + def test_user_error_and_frozen() -> None: @given(st.data()) def user_error_and_frozen(data: DataObject) -> None: raise ExceptionGroup("", [errors.Frozen(), TypeError()]) + with pytest.raises(ExceptionGroup) as excinfo: user_error_and_frozen() e = excinfo.value @@ -44,13 +48,17 @@ def user_error_and_frozen(data: DataObject) -> None: assert isinstance(e.exceptions[0], errors.Frozen) assert isinstance(e.exceptions[1], TypeError) + def test_user_error_and_stoptest() -> None: # if the code base had "proper" handling of exceptiongroups, the StopTest would # probably be handled by an except*. # TODO: which I suppose is an argument in favor of stripping it?? @given(st.data()) def user_error_and_stoptest(data: DataObject) -> None: - raise BaseExceptionGroup("", [errors.StopTest(data.conjecture_data.testcounter), TypeError()]) + raise BaseExceptionGroup( + "", [errors.StopTest(data.conjecture_data.testcounter), TypeError()] + ) + with pytest.raises(BaseExceptionGroup) as excinfo: user_error_and_stoptest() e = excinfo.value @@ -59,12 +67,14 @@ def user_error_and_stoptest(data: DataObject) -> None: assert isinstance(e.exceptions[0], errors.StopTest) assert isinstance(e.exceptions[1], TypeError) + def test_lone_user_error() -> None: # we don't want to unwrap exceptiongroups, since they might contain # useful debugging info @given(st.data()) def lone_user_error(data: DataObject) -> None: raise ExceptionGroup("foo", [TypeError()]) + with pytest.raises(ExceptionGroup) as excinfo: lone_user_error() e = excinfo.value @@ -72,31 +82,98 @@ def lone_user_error(data: DataObject) -> None: assert len(e.exceptions) == 1 assert isinstance(e.exceptions[0], TypeError) + +def test_nested_stoptest() -> None: + @given(st.data()) + def nested_stoptest(data: DataObject) -> None: + raise BaseExceptionGroup( + "", + [ + BaseExceptionGroup( + "", [errors.StopTest(data.conjecture_data.testcounter)] + ) + ], + ) + + nested_stoptest() + + def test_frozen_and_stoptest() -> None: # frozen+stoptest => strip frozen and let engine handle StopTest + # actually.. I don't think I've got a live repo for this either. @given(st.data()) def frozen_and_stoptest(data: DataObject) -> None: - raise BaseExceptionGroup("", [errors.StopTest(data.conjecture_data.testcounter), errors.Frozen()]) + raise BaseExceptionGroup( + "", [errors.StopTest(data.conjecture_data.testcounter), errors.Frozen()] + ) + frozen_and_stoptest() + def test_multiple_stoptest_1() -> None: # multiple stoptest, reraise the one with lowest testcounter - # I don't know if/how this can happen, nested tests perhaps?? + # TODO: I don't know if/how this can happen, nested tests perhaps?? @given(st.data()) def multiple_stoptest(data: DataObject) -> None: c = data.conjecture_data.testcounter - raise BaseExceptionGroup("", [errors.StopTest(c), errors.StopTest(c+1)]) + raise BaseExceptionGroup("", [errors.StopTest(c), errors.StopTest(c + 1)]) + multiple_stoptest() + def test_multiple_stoptest_2() -> None: # the lower value is raised, which does not match data.conjecture_data.testcounter # so it is not handled by the engine @given(st.data()) def multiple_stoptest_2(data: DataObject) -> None: c = data.conjecture_data.testcounter - raise BaseExceptionGroup("", [errors.StopTest(c), errors.StopTest(c-1)]) + raise BaseExceptionGroup("", [errors.StopTest(c), errors.StopTest(c - 1)]) - with pytest.raises(errors.StopTest) as excinfo: + with pytest.raises(errors.StopTest): multiple_stoptest_2() - e = excinfo.value - assert isinstance(e, errors.StopTest) + + +def test_stoptest_and_hypothesisexception() -> None: + # TODO: can this happen? If so what do? + # current code raises the first hypothesisexception and throws away stoptest + @given(st.data()) + def stoptest_and_hypothesisexception(data: DataObject) -> None: + c = data.conjecture_data.testcounter + raise BaseExceptionGroup("", [errors.StopTest(c), errors.FlakyTest()]) + + with pytest.raises(errors.FlakyTest): + stoptest_and_hypothesisexception() + + +def test_multiple_hypothesisexception() -> None: + # this can happen in several ways, see nocover/test_exceptiongroup.py + @given(st.data()) + def stoptest_and_hypothesisexception(data: DataObject) -> None: + raise BaseExceptionGroup("", [errors.StopTest(c), errors.FlakyTest()]) + + with pytest.raises(errors.FlakyTest): + stoptest_and_hypothesisexception() + + +# FIXME: temporarily added while debugging #4115 +def test_recursive_exception(): + @given(st.data()) + def test_function(data): + try: + raise ExceptionGroup("", [ValueError()]) + except ExceptionGroup as eg: + raise eg.exceptions[0] from None + + with pytest.raises(ValueError): + test_function() + + +def test_recursive_exception2(): + @given(st.data()) + def test_function(data): + k = ValueError() + k.__context__ = k + raise k + + with pytest.raises(ValueError): + test_function() diff --git a/hypothesis-python/tests/nocover/test_exceptiongroup.py b/hypothesis-python/tests/nocover/test_exceptiongroup.py index ba1c1b7495..0ed1e71fad 100644 --- a/hypothesis-python/tests/nocover/test_exceptiongroup.py +++ b/hypothesis-python/tests/nocover/test_exceptiongroup.py @@ -193,27 +193,3 @@ async def _main(d: DataObject) -> None: # assert len(e.exceptions) == 2 # assert isinstance(e.exceptions[0], errors.Frozen) # assert isinstance(e.exceptions[1], TypeError) - - -# FIXME: temporarily added while debugging #4115 -def test_recursive_exception(): - @given(st.data()) - def test_function(data): - try: - raise ExceptionGroup("", [ValueError()]) - except ExceptionGroup as eg: - raise eg.exceptions[0] from None - - with pytest.raises(ValueError): - test_function() - - -def test_recursive_exception2(): - @given(st.data()) - def test_function(data): - k = ValueError() - k.__context__ = k - raise k - - with pytest.raises(ValueError): - test_function() From 4e06b62d51d3d05a8882d483209dc00c5d02bf2d Mon Sep 17 00:00:00 2001 From: jakkdl Date: Fri, 27 Sep 2024 13:04:01 +0200 Subject: [PATCH 06/10] uh, why didn't mypy complain about FlakyTest? --- hypothesis-python/tests/cover/test_exceptiongroup.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hypothesis-python/tests/cover/test_exceptiongroup.py b/hypothesis-python/tests/cover/test_exceptiongroup.py index d558d0c983..42d6af70c5 100644 --- a/hypothesis-python/tests/cover/test_exceptiongroup.py +++ b/hypothesis-python/tests/cover/test_exceptiongroup.py @@ -139,9 +139,9 @@ def test_stoptest_and_hypothesisexception() -> None: @given(st.data()) def stoptest_and_hypothesisexception(data: DataObject) -> None: c = data.conjecture_data.testcounter - raise BaseExceptionGroup("", [errors.StopTest(c), errors.FlakyTest()]) + raise BaseExceptionGroup("", [errors.StopTest(c), errors.Flaky()]) - with pytest.raises(errors.FlakyTest): + with pytest.raises(errors.Flaky): stoptest_and_hypothesisexception() @@ -149,9 +149,10 @@ def test_multiple_hypothesisexception() -> None: # this can happen in several ways, see nocover/test_exceptiongroup.py @given(st.data()) def stoptest_and_hypothesisexception(data: DataObject) -> None: - raise BaseExceptionGroup("", [errors.StopTest(c), errors.FlakyTest()]) + c = data.conjecture_data.testcounter + raise BaseExceptionGroup("", [errors.StopTest(c), errors.Flaky()]) - with pytest.raises(errors.FlakyTest): + with pytest.raises(errors.Flaky): stoptest_and_hypothesisexception() From e7e09bde27654769453282d4888e86f7f60841a9 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Fri, 27 Sep 2024 15:12:22 +0200 Subject: [PATCH 07/10] add future annotations to unbreak py38 --- hypothesis-python/src/hypothesis/core.py | 52 ++++++++----------- .../tests/cover/test_exceptiongroup.py | 2 + .../tests/nocover/test_exceptiongroup.py | 2 + 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index 03ad3ef07b..bb9bd36346 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -9,6 +9,7 @@ # obtain one at https://mozilla.org/MPL/2.0/. """This module provides the core primitives of Hypothesis, such as given.""" +from __future__ import annotations import base64 import contextlib @@ -34,12 +35,7 @@ Coroutine, Generator, Hashable, - List, - Optional, - Tuple, - Type, TypeVar, - Union, overload, ) from unittest import TestCase @@ -179,7 +175,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: if not (args or kwargs): raise InvalidArgument("An example must provide at least one argument") - self.hypothesis_explicit_examples: List[Example] = [] + self.hypothesis_explicit_examples: list[Example] = [] self._this_example = Example(tuple(args), kwargs) def __call__(self, test: TestFunc) -> TestFunc: @@ -193,10 +189,8 @@ def xfail( condition: bool = True, # noqa: FBT002 *, reason: str = "", - raises: Union[ - Type[BaseException], Tuple[Type[BaseException], ...] - ] = BaseException, - ) -> "example": + raises: type[BaseException] | tuple[type[BaseException], ...] = BaseException, + ) -> example: """Mark this example as an expected failure, similarly to :obj:`pytest.mark.xfail(strict=True) `. @@ -265,7 +259,7 @@ def test(x): ) return self - def via(self, whence: str, /) -> "example": + def via(self, whence: str, /) -> example: """Attach a machine-readable label noting whence this example came. The idea is that tools will be able to add ``@example()`` cases for you, e.g. @@ -770,7 +764,7 @@ def execute(data, function): @contextlib.contextmanager -def unwrap_exception_group() -> Generator[None, None, None]: +def unwrap_exception_group() -> Generator[None]: T = TypeVar("T", bound=BaseException) def _flatten_group(excgroup: BaseExceptionGroup[T]) -> list[T]: @@ -1205,7 +1199,7 @@ def _execute_once_for_engine(self, data: ConjectureData) -> None: self._timing_features = {} def _deliver_information_message( - self, *, type: str, title: str, content: Union[str, dict] + self, *, type: str, title: str, content: str | dict ) -> None: deliver_json_blob( { @@ -1467,7 +1461,7 @@ class HypothesisHandle: @property def fuzz_one_input( self, - ) -> Callable[[Union[bytes, bytearray, memoryview, BinaryIO]], Optional[bytes]]: + ) -> Callable[[bytes | bytearray | memoryview | BinaryIO], bytes | None]: """Run the test as a fuzz target, driven with the `buffer` of bytes. Returns None if buffer invalid for the strategy, canonical pruned @@ -1488,7 +1482,7 @@ def fuzz_one_input( def given( _: EllipsisType, / ) -> Callable[ - [Callable[..., Optional[Coroutine[Any, Any, None]]]], Callable[[], None] + [Callable[..., Coroutine[Any, Any, None] | None]], Callable[[], None] ]: # pragma: no cover ... @@ -1497,26 +1491,24 @@ def given( def given( *_given_arguments: SearchStrategy[Any], ) -> Callable[ - [Callable[..., Optional[Coroutine[Any, Any, None]]]], Callable[..., None] + [Callable[..., Coroutine[Any, Any, None] | None]], Callable[..., None] ]: # pragma: no cover ... @overload def given( - **_given_kwargs: Union[SearchStrategy[Any], EllipsisType], + **_given_kwargs: SearchStrategy[Any] | EllipsisType, ) -> Callable[ - [Callable[..., Optional[Coroutine[Any, Any, None]]]], Callable[..., None] + [Callable[..., Coroutine[Any, Any, None] | None]], Callable[..., None] ]: # pragma: no cover ... def given( - *_given_arguments: Union[SearchStrategy[Any], EllipsisType], - **_given_kwargs: Union[SearchStrategy[Any], EllipsisType], -) -> Callable[ - [Callable[..., Optional[Coroutine[Any, Any, None]]]], Callable[..., None] -]: + *_given_arguments: SearchStrategy[Any] | EllipsisType, + **_given_kwargs: SearchStrategy[Any] | EllipsisType, +) -> Callable[[Callable[..., Coroutine[Any, Any, None] | None]], Callable[..., None]]: """A decorator for turning a test function that accepts arguments into a randomized test. @@ -1785,7 +1777,7 @@ def wrapped_test(*arguments, **kwargs): raise SKIP_BECAUSE_NO_EXAMPLES def _get_fuzz_target() -> ( - Callable[[Union[bytes, bytearray, memoryview, BinaryIO]], Optional[bytes]] + Callable[[bytes | bytearray | memoryview | BinaryIO], bytes | None] ): # Because fuzzing interfaces are very performance-sensitive, we use a # somewhat more complicated structure here. `_get_fuzz_target()` is @@ -1817,8 +1809,8 @@ def _get_fuzz_target() -> ( minimal_failures: dict = {} def fuzz_one_input( - buffer: Union[bytes, bytearray, memoryview, BinaryIO] - ) -> Optional[bytes]: + buffer: bytes | bytearray | memoryview | BinaryIO, + ) -> bytes | None: # This inner part is all that the fuzzer will actually run, # so we keep it as small and as fast as possible. if isinstance(buffer, io.IOBase): @@ -1872,9 +1864,9 @@ def find( specifier: SearchStrategy[Ex], condition: Callable[[Any], bool], *, - settings: Optional[Settings] = None, - random: Optional[Random] = None, - database_key: Optional[bytes] = None, + settings: Settings | None = None, + random: Random | None = None, + database_key: bytes | None = None, ) -> Ex: """Returns the minimal example from the given strategy ``specifier`` that matches the predicate function ``condition``.""" @@ -1897,7 +1889,7 @@ def find( ) specifier.validate() - last: List[Ex] = [] + last: list[Ex] = [] @settings @given(specifier) diff --git a/hypothesis-python/tests/cover/test_exceptiongroup.py b/hypothesis-python/tests/cover/test_exceptiongroup.py index 42d6af70c5..517974c82f 100644 --- a/hypothesis-python/tests/cover/test_exceptiongroup.py +++ b/hypothesis-python/tests/cover/test_exceptiongroup.py @@ -8,6 +8,8 @@ # v. 2.0. If a copy of the MPL was not distributed with this file, You can # obtain one at https://mozilla.org/MPL/2.0/. +from __future__ import annotations + import pytest from hypothesis import errors, given, strategies as st diff --git a/hypothesis-python/tests/nocover/test_exceptiongroup.py b/hypothesis-python/tests/nocover/test_exceptiongroup.py index 0ed1e71fad..6d82c03a31 100644 --- a/hypothesis-python/tests/nocover/test_exceptiongroup.py +++ b/hypothesis-python/tests/nocover/test_exceptiongroup.py @@ -8,6 +8,8 @@ # v. 2.0. If a copy of the MPL was not distributed with this file, You can # obtain one at https://mozilla.org/MPL/2.0/. +from __future__ import annotations + import asyncio import sys from typing import Callable From 51a6f752243446d72ba68f95cf1f3940b3bab18f Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 1 Oct 2024 15:26:43 +0200 Subject: [PATCH 08/10] undo future annotations import, not sure what to do about __cause__/__context__ --- hypothesis-python/src/hypothesis/core.py | 60 ++++++++++++++---------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index bb9bd36346..774798a5df 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -9,8 +9,6 @@ # obtain one at https://mozilla.org/MPL/2.0/. """This module provides the core primitives of Hypothesis, such as given.""" -from __future__ import annotations - import base64 import contextlib import datetime @@ -35,7 +33,12 @@ Coroutine, Generator, Hashable, + List, + Optional, + Tuple, + Type, TypeVar, + Union, overload, ) from unittest import TestCase @@ -146,6 +149,7 @@ else: # pragma: no cover EllipsisType = type(Ellipsis) + TestFunc = TypeVar("TestFunc", bound=Callable) @@ -175,7 +179,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: if not (args or kwargs): raise InvalidArgument("An example must provide at least one argument") - self.hypothesis_explicit_examples: list[Example] = [] + self.hypothesis_explicit_examples: List[Example] = [] self._this_example = Example(tuple(args), kwargs) def __call__(self, test: TestFunc) -> TestFunc: @@ -189,8 +193,10 @@ def xfail( condition: bool = True, # noqa: FBT002 *, reason: str = "", - raises: type[BaseException] | tuple[type[BaseException], ...] = BaseException, - ) -> example: + raises: Union[ + Type[BaseException], Tuple[Type[BaseException], ...] + ] = BaseException, + ) -> "example": """Mark this example as an expected failure, similarly to :obj:`pytest.mark.xfail(strict=True) `. @@ -259,7 +265,7 @@ def test(x): ) return self - def via(self, whence: str, /) -> example: + def via(self, whence: str, /) -> "example": """Attach a machine-readable label noting whence this example came. The idea is that tools will be able to add ``@example()`` cases for you, e.g. @@ -800,7 +806,10 @@ def _flatten_group(excgroup: BaseExceptionGroup[T]) -> list[T]: # single marker exception - reraise it flattened_non_frozen_exceptions = _flatten_group(non_frozen_exceptions) if len(flattened_non_frozen_exceptions) == 1: - raise flattened_non_frozen_exceptions[0] from None + e = flattened_non_frozen_exceptions[0] + # preserve the cause of the original exception to not hinder debugging + # note that __context__ is still lost though + raise e from e.__cause__ # multiple marker exceptions. If we re-raise the whole group we break # a bunch of logic so ....? @@ -811,7 +820,8 @@ def _flatten_group(excgroup: BaseExceptionGroup[T]) -> list[T]: if non_stoptests: # TODO: multiple marker exceptions is easy to produce, but the logic in the # engine does not handle it... so we just reraise the first one for now. - raise _flatten_group(non_stoptests)[0] from None + e = _flatten_group(non_stoptests)[0] + raise e from e.__cause__ assert stoptests is not None # multiple stoptests: raising the one with the lowest testcounter @@ -1199,7 +1209,7 @@ def _execute_once_for_engine(self, data: ConjectureData) -> None: self._timing_features = {} def _deliver_information_message( - self, *, type: str, title: str, content: str | dict + self, *, type: str, title: str, content: Union[str, dict] ) -> None: deliver_json_blob( { @@ -1461,7 +1471,7 @@ class HypothesisHandle: @property def fuzz_one_input( self, - ) -> Callable[[bytes | bytearray | memoryview | BinaryIO], bytes | None]: + ) -> Callable[[Union[bytes, bytearray, memoryview, BinaryIO]], Optional[bytes]]: """Run the test as a fuzz target, driven with the `buffer` of bytes. Returns None if buffer invalid for the strategy, canonical pruned @@ -1482,7 +1492,7 @@ def fuzz_one_input( def given( _: EllipsisType, / ) -> Callable[ - [Callable[..., Coroutine[Any, Any, None] | None]], Callable[[], None] + [Callable[..., Optional[Coroutine[Any, Any, None]]]], Callable[[], None] ]: # pragma: no cover ... @@ -1491,24 +1501,26 @@ def given( def given( *_given_arguments: SearchStrategy[Any], ) -> Callable[ - [Callable[..., Coroutine[Any, Any, None] | None]], Callable[..., None] + [Callable[..., Optional[Coroutine[Any, Any, None]]]], Callable[..., None] ]: # pragma: no cover ... @overload def given( - **_given_kwargs: SearchStrategy[Any] | EllipsisType, + **_given_kwargs: Union[SearchStrategy[Any], EllipsisType], ) -> Callable[ - [Callable[..., Coroutine[Any, Any, None] | None]], Callable[..., None] + [Callable[..., Optional[Coroutine[Any, Any, None]]]], Callable[..., None] ]: # pragma: no cover ... def given( - *_given_arguments: SearchStrategy[Any] | EllipsisType, - **_given_kwargs: SearchStrategy[Any] | EllipsisType, -) -> Callable[[Callable[..., Coroutine[Any, Any, None] | None]], Callable[..., None]]: + *_given_arguments: Union[SearchStrategy[Any], EllipsisType], + **_given_kwargs: Union[SearchStrategy[Any], EllipsisType], +) -> Callable[ + [Callable[..., Optional[Coroutine[Any, Any, None]]]], Callable[..., None] +]: """A decorator for turning a test function that accepts arguments into a randomized test. @@ -1777,7 +1789,7 @@ def wrapped_test(*arguments, **kwargs): raise SKIP_BECAUSE_NO_EXAMPLES def _get_fuzz_target() -> ( - Callable[[bytes | bytearray | memoryview | BinaryIO], bytes | None] + Callable[[Union[bytes, bytearray, memoryview, BinaryIO]], Optional[bytes]] ): # Because fuzzing interfaces are very performance-sensitive, we use a # somewhat more complicated structure here. `_get_fuzz_target()` is @@ -1809,8 +1821,8 @@ def _get_fuzz_target() -> ( minimal_failures: dict = {} def fuzz_one_input( - buffer: bytes | bytearray | memoryview | BinaryIO, - ) -> bytes | None: + buffer: Union[bytes, bytearray, memoryview, BinaryIO] + ) -> Optional[bytes]: # This inner part is all that the fuzzer will actually run, # so we keep it as small and as fast as possible. if isinstance(buffer, io.IOBase): @@ -1864,9 +1876,9 @@ def find( specifier: SearchStrategy[Ex], condition: Callable[[Any], bool], *, - settings: Settings | None = None, - random: Random | None = None, - database_key: bytes | None = None, + settings: Optional[Settings] = None, + random: Optional[Random] = None, + database_key: Optional[bytes] = None, ) -> Ex: """Returns the minimal example from the given strategy ``specifier`` that matches the predicate function ``condition``.""" @@ -1889,7 +1901,7 @@ def find( ) specifier.validate() - last: list[Ex] = [] + last: List[Ex] = [] @settings @given(specifier) From d3fd6d21aa06b565fa2222c0532cb868b1475461 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 1 Oct 2024 15:41:51 +0200 Subject: [PATCH 09/10] and Generator --- hypothesis-python/src/hypothesis/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index 774798a5df..a98822c8bf 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -770,7 +770,7 @@ def execute(data, function): @contextlib.contextmanager -def unwrap_exception_group() -> Generator[None]: +def unwrap_exception_group() -> Generator[None, None, None]: T = TypeVar("T", bound=BaseException) def _flatten_group(excgroup: BaseExceptionGroup[T]) -> list[T]: From 3144dddf54089afa9381122acb9eef5123c7b28b Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 1 Oct 2024 16:00:10 +0200 Subject: [PATCH 10/10] type checking/linting is a mess --- hypothesis-python/src/hypothesis/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index a98822c8bf..354c9615aa 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -773,8 +773,8 @@ def execute(data, function): def unwrap_exception_group() -> Generator[None, None, None]: T = TypeVar("T", bound=BaseException) - def _flatten_group(excgroup: BaseExceptionGroup[T]) -> list[T]: - found_exceptions = [] + def _flatten_group(excgroup: BaseExceptionGroup[T]) -> List[T]: + found_exceptions: List[T] = [] for exc in excgroup.exceptions: if isinstance(exc, BaseExceptionGroup): found_exceptions.extend(_flatten_group(exc)) @@ -804,7 +804,7 @@ def _flatten_group(excgroup: BaseExceptionGroup[T]) -> list[T]: raise # single marker exception - reraise it - flattened_non_frozen_exceptions = _flatten_group(non_frozen_exceptions) + flattened_non_frozen_exceptions: List[BaseException] = _flatten_group(non_frozen_exceptions) if len(flattened_non_frozen_exceptions) == 1: e = flattened_non_frozen_exceptions[0] # preserve the cause of the original exception to not hinder debugging