Skip to content

Commit

Permalink
Merge pull request #34 from elchupanebrej/multiple_handled_exception
Browse files Browse the repository at this point in the history
Allow bypass only specific exceptions during waiting
  • Loading branch information
Dmitrii Misharov authored Sep 29, 2021
2 parents c5a7f22 + 7d9cee2 commit 9fca7da
Show file tree
Hide file tree
Showing 3 changed files with 201 additions and 44 deletions.
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = wait_for
author = Peter Savage
author_email = [email protected]
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 =
Expand Down
153 changes: 135 additions & 18 deletions tests/test_exception.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from itertools import cycle
from typing import Union, Type

import pytest

from wait_for import wait_for, TimedOutError
Expand All @@ -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():
Expand All @@ -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():
Expand All @@ -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():
Expand All @@ -37,27 +47,134 @@ 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():
"""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_(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_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_exception_silent_failure_v1():

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)
90 changes: 65 additions & 25 deletions wait_for/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from functools import partial
from threading import Timer
from types import LambdaType
from typing import Iterable, Union, Type

import parsedatetime

Expand Down Expand Up @@ -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,
Expand All @@ -104,36 +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,
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.
delay: An integer describing the number of seconds to delay before trying func()
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
Expand Down Expand Up @@ -162,6 +199,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:
Expand All @@ -174,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 "
Expand Down Expand Up @@ -233,9 +271,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})
Expand Down

0 comments on commit 9fca7da

Please sign in to comment.