From 8b27cc22340d8945c08172817f09a11a7ec9a60d Mon Sep 17 00:00:00 2001 From: Konstantin Goloveshko Date: Fri, 12 Mar 2021 13:40:08 +0200 Subject: [PATCH 1/2] Handling exception raises TimedOutError from last occurred exception --- tests/test_exception.py | 14 ++++++++++++++ wait_for/__init__.py | 9 +++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/test_exception.py b/tests/test_exception.py index 28be98c..75b792b 100644 --- a/tests/test_exception.py +++ b/tests/test_exception.py @@ -40,6 +40,20 @@ def test_handle_exception_v3(): wait_for(raise_my_error, handle_exception=True, num_sec=0.1) +def test_handle_exception_raises_TimedOutError_from_occured_exception(): + """Set ``handle_exception`` to true. + + An exception raised by the waited-upon function should not bubble up, and a + ``TimedOutError`` should be raised from function-occurred exception instead. + """ + try: + wait_for(raise_my_error, handle_exception=True, num_sec=0.1) + except TimedOutError as timeout_exception: + assert isinstance(timeout_exception.__cause__, MyError) + else: + assert False, "Wasn't raised" + + def test_handle_exception_silent_failure_v1(): """Set both ``handle_exception`` and ``silent_failure`` to true. diff --git a/wait_for/__init__.py b/wait_for/__init__.py index 3184343..b91e808 100644 --- a/wait_for/__init__.py +++ b/wait_for/__init__.py @@ -121,7 +121,9 @@ def wait_for(func, func_args=[], func_kwargs={}, logger=None, **kwargs): iterable. handle_exception: A boolean controlling the handling of excepetions during func() invocation. If set to True, in cases where func() results in an exception, - clobber the exception and treat it as a fail_condition. + clobber the exception and treat it as a fail_condition; If timed out during handling + exception TimedOutError would be raised from last handled exception + raise_original: A boolean controlling if last original exception would be raised on timeout delay: An integer describing the number of seconds to delay before trying func() again. fail_func: A function to be run after every unsuccessful attempt to run func() @@ -162,6 +164,7 @@ def wait_for(func, func_args=[], func_kwargs={}, logger=None, **kwargs): tries = 0 out = None exc = None + if not very_quiet: logger.debug("Started %(message)r at %(time).2f", {'message': message, 'time': st_time}) while t_delta <= num_sec: @@ -233,9 +236,11 @@ def wait_for(func, func_args=[], func_kwargs={}, logger=None, **kwargs): if not silent_fail: logger.error(logger_fmt, logger_dict) logger.error('The last result of the call was: %(result)r', {'result': out}) + if raise_original and exc: raise exc - raise TimedOutError(timeout_msg) + else: + raise TimedOutError(timeout_msg) from exc else: logger.warning("{} but ignoring".format(logger_fmt), logger_dict) logger.warning('The last result of the call was: %(result)r', {'result': out}) From 7d9cee29f088248a00bcaead78e6210d0adfdf29 Mon Sep 17 00:00:00 2001 From: Konstantin Goloveshko Date: Fri, 12 Mar 2021 15:41:32 +0200 Subject: [PATCH 2/2] Allow bypass only specific exceptions during waiting --- setup.cfg | 2 +- tests/test_exception.py | 141 ++++++++++++++++++++++++++++++++++------ wait_for/__init__.py | 89 +++++++++++++++++-------- 3 files changed, 185 insertions(+), 47 deletions(-) diff --git a/setup.cfg b/setup.cfg index 3945e16..7271ec1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ name = wait_for author = Peter Savage author_email = psavage@redhat.com summary = A waiting based utility with decorator and logger support -description-file = README.rst +long_description = file: README.rst url = https://github.com/RedHatQE/wait_for license = Apache classifier = diff --git a/tests/test_exception.py b/tests/test_exception.py index 75b792b..6b260bc 100644 --- a/tests/test_exception.py +++ b/tests/test_exception.py @@ -1,3 +1,6 @@ +from itertools import cycle +from typing import Union, Type + import pytest from wait_for import wait_for, TimedOutError @@ -7,9 +10,16 @@ class MyError(Exception): """A sample exception for use by the tests in this module.""" -def raise_my_error(): - """Raise ``MyError``.""" - raise MyError() +class AnotherError(Exception): + """A sample exception for use by the tests in this module.""" + + +def raise_(*exceptions: Union[Exception, Type[Exception]], default=MyError): + _exceptions = cycle(exceptions or [default]) + + def raisable(): + raise next(_exceptions) + return raisable def test_handle_exception_v1(): @@ -18,7 +28,7 @@ def test_handle_exception_v1(): An exception raised by the waited-upon function should bubble up. """ with pytest.raises(MyError): - wait_for(raise_my_error) + wait_for(raise_(MyError)) def test_handle_exception_v2(): @@ -27,7 +37,7 @@ def test_handle_exception_v2(): An exception raised by the waited-upon function should bubble up. """ with pytest.raises(MyError): - wait_for(raise_my_error, handle_exception=False) + wait_for(raise_(MyError), handle_exception=False) def test_handle_exception_v3(): @@ -37,7 +47,7 @@ def test_handle_exception_v3(): ``TimedOutError`` should be raised instead. """ with pytest.raises(TimedOutError): - wait_for(raise_my_error, handle_exception=True, num_sec=0.1) + wait_for(raise_(MyError), handle_exception=True, num_sec=0.1) def test_handle_exception_raises_TimedOutError_from_occured_exception(): @@ -47,31 +57,124 @@ def test_handle_exception_raises_TimedOutError_from_occured_exception(): ``TimedOutError`` should be raised from function-occurred exception instead. """ try: - wait_for(raise_my_error, handle_exception=True, num_sec=0.1) + wait_for(raise_(MyError), handle_exception=True, num_sec=0.1) except TimedOutError as timeout_exception: assert isinstance(timeout_exception.__cause__, MyError) else: assert False, "Wasn't raised" -def test_handle_exception_silent_failure_v1(): +def test_handle_specific_exception(): + """Set ``handle_exception`` to ``MyError``. + + An exception raised by the waited-upon function should not bubble up, and a + ``TimedOutError`` should be raised. + """ + with pytest.raises(TimedOutError): + wait_for(raise_(MyError), handle_exception=MyError, num_sec=0.1) + + +def test_handle_specific_exception_in_iterable(): + """Set ``handle_exception`` to ``(MyError,)``. + + An exception raised by the waited-upon function should not bubble up, and a + ``TimedOutError`` should be raised. + """ + with pytest.raises(TimedOutError): + wait_for(raise_(MyError), handle_exception=(MyError,), num_sec=0.1) + + +def test_handle_specific_exception_from_general_one(): + """Set ``handle_exception`` to ``(Exception,)``. + + An exception raised by the waited-upon function should not bubble up, and a + ``TimedOutError`` should be raised. + """ + with pytest.raises(TimedOutError): + wait_for(raise_(MyError), handle_exception=(Exception,), num_sec=0.1) + + +def test_handle_specific_exceptions_in_iterable(): + """Set ``handle_exception`` to ``(MyError, AnotherError,)``. + + An exception raised by the waited-upon function should not bubble up, and a + ``TimedOutError`` should be raised. + """ + with pytest.raises(TimedOutError): + wait_for(raise_(MyError, AnotherError, MyError(), AnotherError()), + handle_exception=(MyError, AnotherError,), + num_sec=0.1) + + +@pytest.mark.parametrize('handle_exception', [ + cycle([1, ]), + 'foo_string', + (MyError('Here'), AnotherError('There')) +]) +def test_handle_exception_in_iterable_containing_not_exception_types_are_interpreted_as_True( + handle_exception +): + """Set ``handle_exception`` to non-empty iterable containing non-Exception types instances. + + An exception raised by the waited-upon function should not bubble up, and a + ``TimedOutError`` should be raised because in such case iterable is evaluated to True + """ + with pytest.raises(TimedOutError): + wait_for( + raise_( + MyError, AnotherError, MyError(), AnotherError(), RuntimeError, RuntimeError('Foo') + ), + handle_exception=handle_exception, + num_sec=1, + delay=0.1 + ) + + +@pytest.mark.parametrize('handle_exception, _', [ # _ - is workaround for minor pytest bug + (cycle([]), 1), + ('', 2), + (set(), 3), + ([], 4), +]) +def test_handle_exceptions_in_empty_iterable_are_interpreted_as_False(handle_exception, _): + """Set ``handle_exception`` to empty iterable + + An exception raised by the waited-upon function should bubble up. + """ + with pytest.raises(MyError): + wait_for(raise_(MyError), handle_exception=handle_exception, num_sec=1, delay=0.1) + + +def test_not_handle_unexpected_exception(): + """Set ``handle_exception`` to ``MyError``. + + An exception raised by the waited-upon function should bubble up, and a + ``AnotherError`` should be raised. + """ + with pytest.raises(AnotherError): + wait_for(raise_(AnotherError), handle_exception=MyError, num_sec=0.1) + + +def test_not_handle_unexpected_exceptions(): + """Set ``handle_exception`` to ``(ValueError, RuntimeError,)``. + + An exception raised by the waited-upon function should bubble up, and a + ``AnotherError`` should be raised. + """ + with pytest.raises(AnotherError): + wait_for(raise_(AnotherError), handle_exception=(ValueError, RuntimeError,), num_sec=0.1) + + +def test_handle_exception_silent_failure(): """Set both ``handle_exception`` and ``silent_failure`` to true. The time spent calling the waited-upon function should be returned. """ - _, num_sec = _call_handle_exception_silent_failure() + _, num_sec = wait_for(raise_(MyError), handle_exception=True, num_sec=0.1, silent_failure=True,) assert isinstance(num_sec, float) def test_reraise_exception(): + """Original exception is re-raised""" with pytest.raises(MyError): - wait_for(raise_my_error, handle_exception=True, num_sec=0.1, raise_original=True) - - -def _call_handle_exception_silent_failure(): - return wait_for( - raise_my_error, - handle_exception=True, - num_sec=0.1, - silent_failure=True, - ) + wait_for(raise_(MyError), handle_exception=True, num_sec=0.1, raise_original=True) diff --git a/wait_for/__init__.py b/wait_for/__init__.py index b91e808..28e910f 100644 --- a/wait_for/__init__.py +++ b/wait_for/__init__.py @@ -6,6 +6,7 @@ from functools import partial from threading import Timer from types import LambdaType +from typing import Iterable, Union, Type import parsedatetime @@ -89,6 +90,32 @@ def _get_failcondition_check(fail_condition): return partial(check_result_is_fail_condition, fail_condition) +def _is_exception_type(obj): + return isinstance(obj, type) and issubclass(obj, Exception) + + +def _get_handled_exceptions( + handle: Union[Type[Exception], Iterable[Type[Exception]]] +) -> Iterable[Type[Exception]]: + if _is_exception_type(handle): + return iter((handle,)) + else: + if isinstance(handle, Iterable): + return iter(item if _is_exception_type(item) else Exception for item in handle) + else: + return iter((Exception,)) + + +def _check_must_be_handled( + exception: Exception, handle: Union[Type[Exception], Iterable[Type[Exception]]] +) -> bool: + return handle and any( + exc_type for exc_type + in _get_handled_exceptions(handle) + if isinstance(exception, exc_type) + ) + + def wait_for(func, func_args=[], func_kwargs={}, logger=None, **kwargs): """Waits for a certain amount of time for an action to complete Designed to wait for a certain length of time, @@ -104,38 +131,46 @@ def wait_for(func, func_args=[], func_kwargs={}, logger=None, **kwargs): correctly, only that it returned correctly at last check. Args: - func: A function to be run - func_args: A list of function arguments to be passed to func - func_kwargs: A dict of function keyword arguments to be passed to func - num_sec: An int describing the number of seconds to wait before timing out. - timeout: Either an int describing the number of seconds to wait before timing out. Or a - :py:class:`timedelta` object. Or a string formatted like ``1h 10m 5s``. This then sets - the ``num_sec`` variable. - expo: A boolean flag toggling exponential delay growth. - message: A string containing a description of func's operation. If None, - defaults to the function's name. - fail_condition: An object describing the failure condition that should be tested - against the output of func. If func() == fail_condition, wait_for continues - to wait. Can be a callable which takes the result and returns boolean whether to fail. + func (callable): A function to be run + func_args (Iterable[Any]): A list of function arguments to be passed to func + func_kwargs (dict[str, Any]): A dict of function keyword arguments to be passed to func + num_sec (int): An int describing the number of seconds to wait before timing out. + timeout (Union[int, timedelta, str]): Describes time to wait before timing out. + Either an int describing the number of seconds. + Or a :py:class:`timedelta` object. + Or a string formatted like ``1h 10m 5s``. + This then sets the ``num_sec`` variable. + expo (Any): A flag toggling exponential delay growth. + message (Optional[str]): A description of func's operation. If None, defaults to the + function's name. + fail_condition (Union[callable, Any, set[Any]]): An object describing the failure + condition that should be tested against the output of func. + If func() == fail_condition, wait_for continues to wait. + Can be a callable which takes the result and returns boolean whether to fail. You can also specify it as a set, that way it checks whether it is present in the iterable. - handle_exception: A boolean controlling the handling of excepetions during func() - invocation. If set to True, in cases where func() results in an exception, - clobber the exception and treat it as a fail_condition; If timed out during handling - exception TimedOutError would be raised from last handled exception - raise_original: A boolean controlling if last original exception would be raised on timeout - delay: An integer describing the number of seconds to delay before trying func() + handle_exception(Union[Type[Exception], Iterable[Type[Exception]], Any]): + A parameter for the handling of exceptions during func() invocation. + If set to ``Union[Type[Exception], Iterable[Type[Exception]]`` clobber exception + just from listed exceptions and treat it as a fail_condition. + If could be casted to True, in cases where func() results in an exception, + clobber the exception and treat it as a fail_condition. + If timed out during handling exception TimedOutError would be raised from last handled + exception. + raise_original (bool): Controls if last original exception would be raised on timeout + delay (int): An integer describing the number of seconds to delay before trying func() again. - fail_func: A function to be run after every unsuccessful attempt to run func() - quiet: Do not write time report to the log (default False) - very_quiet: Do not log unless there was an error (default False). Implies quiet. - silent_failure: Even if the entire attempt times out, don't throw a exception. - log_on_loop: Fire off a log.info message indicating we're still waiting at each + fail_func (callable): A function to be run after every unsuccessful attempt to run func() + quiet (Any): Do not write time report to the log (default False) + very_quiet (Any): Do not log unless there was an error (default False). Implies quiet. + silent_failure (Any): Even if the entire attempt times out, don't throw a exception. + log_on_loop (Any): Fire off a log.info message indicating we're still waiting at each iteration of the wait loop Returns: - A tuple containing the output from func() and a float detailing the total wait time. + Tuple[Any, float]: Output from func() and total wait time. Raises: - TimedOutError: If num_sec is exceeded after an unsuccessful func() invocation. + TimedOutError: If num_sec is exceeded after an unsuccessful func() invocation and silent + failure is not set """ # Hide this call in the detailed traceback # https://docs.pytest.org/en/latest/example/simple.html#writing-well-integrated-assertion-helpers @@ -177,7 +212,7 @@ def wait_for(func, func_args=[], func_kwargs={}, logger=None, **kwargs): logger.info( "wait_for hit an exception: %(exc_name)s: %(exc)s", {'exc_name': type(e).__name__, 'exc': e}) - if handle_exception: + if _check_must_be_handled(e, handle_exception): out = fail_condition exc = e logger.info("Call failed with following exception, but continuing "