From 31fe2d0cf2505bffd0cf1ffda8c7e30450ce709f Mon Sep 17 00:00:00 2001 From: hasier Date: Fri, 5 Jul 2024 08:22:20 +0100 Subject: [PATCH] fix: Restore contents of retry attribute for wrapped functions (#484) * Restore retry attribute in wrapped functions * Add tests for wrapped function attributes * Update docs and add release note --- doc/source/index.rst | 36 +++++++++-- ...y-wrapper-attributes-f7a3a45b8e90f257.yaml | 6 ++ tenacity/__init__.py | 2 +- tenacity/asyncio/__init__.py | 2 +- tests/test_asyncio.py | 61 ++++++++++++++++++- tests/test_tenacity.py | 58 +++++++++++++++--- 6 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml diff --git a/doc/source/index.rst b/doc/source/index.rst index 65dd208..928ddd9 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -124,8 +124,8 @@ retrying stuff. print("Stopping after 10 seconds") raise Exception -If you're on a tight deadline, and exceeding your delay time isn't ok, -then you can give up on retries one attempt before you would exceed the delay. +If you're on a tight deadline, and exceeding your delay time isn't ok, +then you can give up on retries one attempt before you would exceed the delay. .. testcode:: @@ -362,7 +362,7 @@ Statistics ~~~~~~~~~~ You can access the statistics about the retry made over a function by using the -`retry` attribute attached to the function and its `statistics` attribute: +`statistics` attribute attached to the function: .. testcode:: @@ -375,7 +375,7 @@ You can access the statistics about the retry made over a function by using the except Exception: pass - print(raise_my_exception.retry.statistics) + print(raise_my_exception.statistics) .. testoutput:: :hide: @@ -495,7 +495,7 @@ using the `retry_with` function attached to the wrapped function: except Exception: pass - print(raise_my_exception.retry.statistics) + print(raise_my_exception.statistics) .. testoutput:: :hide: @@ -514,6 +514,32 @@ to use the `retry` decorator - you can instead use `Retrying` directly: retryer = Retrying(stop=stop_after_attempt(max_attempts), reraise=True) retryer(never_good_enough, 'I really do try') +You may also want to change the behaviour of a decorated function temporarily, +like in tests to avoid unnecessary wait times. You can modify/patch the `retry` +attribute attached to the function. Bear in mind this is a write-only attribute, +statistics should be read from the function `statistics` attribute. + +.. testcode:: + + @retry(stop=stop_after_attempt(3), wait=wait_fixed(3)) + def raise_my_exception(): + raise MyException("Fail") + + from unittest import mock + + with mock.patch.object(raise_my_exception.retry, "wait", wait_fixed(0)): + try: + raise_my_exception() + except Exception: + pass + + print(raise_my_exception.statistics) + +.. testoutput:: + :hide: + + ... + Retrying code block ~~~~~~~~~~~~~~~~~~~ diff --git a/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml b/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml new file mode 100644 index 0000000..967cd29 --- /dev/null +++ b/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + Restore the value of the `retry` attribute for wrapped functions. Also, + clarify that those attributes are write-only and statistics should be + read from the function attribute directly. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 06251ed..02057a0 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -339,7 +339,7 @@ def retry_with(*args: t.Any, **kwargs: t.Any) -> WrappedFn: return self.copy(*args, **kwargs).wraps(f) # Preserve attributes - wrapped_f.retry = wrapped_f # type: ignore[attr-defined] + wrapped_f.retry = self # type: ignore[attr-defined] wrapped_f.retry_with = retry_with # type: ignore[attr-defined] wrapped_f.statistics = {} # type: ignore[attr-defined] diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index 38b76c7..a926091 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -189,7 +189,7 @@ async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any: return await copy(fn, *args, **kwargs) # Preserve attributes - async_wrapped.retry = async_wrapped # type: ignore[attr-defined] + async_wrapped.retry = self # type: ignore[attr-defined] async_wrapped.retry_with = wrapped.retry_with # type: ignore[attr-defined] async_wrapped.statistics = {} # type: ignore[attr-defined] diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 8716529..0b74476 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -17,6 +17,7 @@ import inspect import unittest from functools import wraps +from unittest import mock try: import trio @@ -59,7 +60,7 @@ async def _retryable_coroutine(thing): @retry(stop=stop_after_attempt(2)) async def _retryable_coroutine_with_2_attempts(thing): await asyncio.sleep(0.00001) - thing.go() + return thing.go() class TestAsyncio(unittest.TestCase): @@ -394,6 +395,64 @@ async def test_async_retying_iterator(self): await _async_function(thing) +class TestDecoratorWrapper(unittest.TestCase): + @asynctest + async def test_retry_function_attributes(self): + """Test that the wrapped function attributes are exposed as intended. + + - statistics contains the value for the latest function run + - retry object can be modified to change its behaviour (useful to patch in tests) + - retry object statistics do not contain valid information + """ + + self.assertTrue( + await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(1)) + ) + + expected_stats = { + "attempt_number": 2, + "delay_since_first_attempt": mock.ANY, + "idle_for": mock.ANY, + "start_time": mock.ANY, + } + self.assertEqual( + _retryable_coroutine_with_2_attempts.statistics, # type: ignore[attr-defined] + expected_stats, + ) + self.assertEqual( + _retryable_coroutine_with_2_attempts.retry.statistics, # type: ignore[attr-defined] + {}, + ) + + with mock.patch.object( + _retryable_coroutine_with_2_attempts.retry, # type: ignore[attr-defined] + "stop", + tenacity.stop_after_attempt(1), + ): + try: + self.assertTrue( + await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(2)) + ) + except RetryError as exc: + expected_stats = { + "attempt_number": 1, + "delay_since_first_attempt": mock.ANY, + "idle_for": mock.ANY, + "start_time": mock.ANY, + } + self.assertEqual( + _retryable_coroutine_with_2_attempts.statistics, # type: ignore[attr-defined] + expected_stats, + ) + self.assertEqual(exc.last_attempt.attempt_number, 1) + self.assertEqual( + _retryable_coroutine_with_2_attempts.retry.statistics, # type: ignore[attr-defined] + {}, + ) + else: + self.fail("RetryError should have been raised after 1 attempt") + + # make sure mypy accepts passing an async sleep function # https://github.com/jd/tenacity/issues/399 async def my_async_sleep(x: float) -> None: diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index e158fa6..ecc0312 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -25,6 +25,7 @@ from contextlib import contextmanager from copy import copy from fractions import Fraction +from unittest import mock import pytest @@ -1073,7 +1074,7 @@ def test_retry_until_exception_of_type_attempt_number(self): _retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5)) ) except NameError as e: - s = _retryable_test_with_unless_exception_type_name.retry.statistics + s = _retryable_test_with_unless_exception_type_name.statistics self.assertTrue(s["attempt_number"] == 6) print(e) else: @@ -1088,7 +1089,7 @@ def test_retry_until_exception_of_type_no_type(self): ) ) except NameError as e: - s = _retryable_test_with_unless_exception_type_no_input.retry.statistics + s = _retryable_test_with_unless_exception_type_no_input.statistics self.assertTrue(s["attempt_number"] == 6) print(e) else: @@ -1111,7 +1112,7 @@ def test_retry_if_exception_message(self): _retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3)) ) except CustomError: - print(_retryable_test_if_exception_message_message.retry.statistics) + print(_retryable_test_if_exception_message_message.statistics) self.fail("CustomError should've been retried from errormessage") def test_retry_if_not_exception_message(self): @@ -1122,7 +1123,7 @@ def test_retry_if_not_exception_message(self): ) ) except CustomError: - s = _retryable_test_if_not_exception_message_message.retry.statistics + s = _retryable_test_if_not_exception_message_message.statistics self.assertTrue(s["attempt_number"] == 1) def test_retry_if_not_exception_message_delay(self): @@ -1131,7 +1132,7 @@ def test_retry_if_not_exception_message_delay(self): _retryable_test_not_exception_message_delay(NameErrorUntilCount(3)) ) except NameError: - s = _retryable_test_not_exception_message_delay.retry.statistics + s = _retryable_test_not_exception_message_delay.statistics print(s["attempt_number"]) self.assertTrue(s["attempt_number"] == 4) @@ -1151,7 +1152,7 @@ def test_retry_if_not_exception_message_match(self): ) ) except CustomError: - s = _retryable_test_if_not_exception_message_message.retry.statistics + s = _retryable_test_if_not_exception_message_message.statistics self.assertTrue(s["attempt_number"] == 1) def test_retry_if_exception_cause_type(self): @@ -1209,6 +1210,43 @@ def __call__(self): h = retrying.wraps(Hello()) self.assertEqual(h(), "Hello") + def test_retry_function_attributes(self): + """Test that the wrapped function attributes are exposed as intended. + + - statistics contains the value for the latest function run + - retry object can be modified to change its behaviour (useful to patch in tests) + - retry object statistics do not contain valid information + """ + + self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2))) + + expected_stats = { + "attempt_number": 3, + "delay_since_first_attempt": mock.ANY, + "idle_for": mock.ANY, + "start_time": mock.ANY, + } + self.assertEqual(_retryable_test_with_stop.statistics, expected_stats) + self.assertEqual(_retryable_test_with_stop.retry.statistics, {}) + + with mock.patch.object( + _retryable_test_with_stop.retry, "stop", tenacity.stop_after_attempt(1) + ): + try: + self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2))) + except RetryError as exc: + expected_stats = { + "attempt_number": 1, + "delay_since_first_attempt": mock.ANY, + "idle_for": mock.ANY, + "start_time": mock.ANY, + } + self.assertEqual(_retryable_test_with_stop.statistics, expected_stats) + self.assertEqual(exc.last_attempt.attempt_number, 1) + self.assertEqual(_retryable_test_with_stop.retry.statistics, {}) + else: + self.fail("RetryError should have been raised after 1 attempt") + class TestRetryWith: def test_redefine_wait(self): @@ -1479,21 +1517,21 @@ def test_stats(self): def _foobar(): return 42 - self.assertEqual({}, _foobar.retry.statistics) + self.assertEqual({}, _foobar.statistics) _foobar() - self.assertEqual(1, _foobar.retry.statistics["attempt_number"]) + self.assertEqual(1, _foobar.statistics["attempt_number"]) def test_stats_failing(self): @retry(stop=tenacity.stop_after_attempt(2)) def _foobar(): raise ValueError(42) - self.assertEqual({}, _foobar.retry.statistics) + self.assertEqual({}, _foobar.statistics) try: _foobar() except Exception: # noqa: B902 pass - self.assertEqual(2, _foobar.retry.statistics["attempt_number"]) + self.assertEqual(2, _foobar.statistics["attempt_number"]) class TestRetryErrorCallback(unittest.TestCase):